diff --git a/.gitignore b/.gitignore index 789538f..46665ba 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,6 @@ gin-bin # iruka artifacts # artifacts -iruka +iruka/iruka +irukad/irukad iruka.yml diff --git a/agent/agent.go b/agent/agent.go index 3f40b29..d5e72ef 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -3,6 +3,7 @@ package agent import ( "fmt" "os" + "strconv" "strings" "time" @@ -10,6 +11,7 @@ import ( "github.com/fsouza/go-dockerclient" "github.com/spesnova/iruka/registry" + "github.com/spesnova/iruka/router" "github.com/spesnova/iruka/schema" ) @@ -25,10 +27,11 @@ type Agent interface { type IrukaAgent struct { docker *docker.Client reg *registry.Registry + rou *router.Router Machine string } -func NewAgent(host, machine string, reg *registry.Registry) Agent { +func NewAgent(host, machine string, reg *registry.Registry, rou *router.Router) Agent { if os.Getenv("IRUKA_DOCKER_HOST") != "" { host = os.Getenv("IRUKA_DOCKER_HOST") } @@ -44,6 +47,7 @@ func NewAgent(host, machine string, reg *registry.Registry) Agent { return &IrukaAgent{ docker: client, reg: reg, + rou: rou, Machine: machine, } } @@ -61,7 +65,11 @@ func (a *IrukaAgent) Pulse() { // Regist only containers that managed by iruka s := strings.Split(name, ".") if uuid.Parse((s[len(s)-1])) == nil { - fmt.Println("Skipped to register:", name) + // Vulcand is always running with iruka + if name != "irukad" && name != "vulcand" { + fmt.Println("Skipped to register:", name) + } + continue } @@ -76,9 +84,23 @@ func (a *IrukaAgent) Pulse() { PublishedPort: container.Ports[0].PublicPort, } - _, err := a.reg.UpdateContainerState(name, opts) + c, err := a.reg.UpdateContainerState(name, opts) if err != nil { fmt.Println(err.Error()) + continue + } + + url := "http://" + c.Machine + ":" + strconv.FormatInt(c.PublishedPort, 10) + + if !a.rou.IsServerExists(c.AppID.String(), name) { + err = a.rou.AddServer(c.AppID.String(), name, url) + + if err != nil { + fmt.Println(err.Error()) + continue + } + + fmt.Printf("Registered new container to %s: %s\n", c.AppID.String(), name) } } diff --git a/client/domain.go b/client/domain.go new file mode 100644 index 0000000..9bb8fc0 --- /dev/null +++ b/client/domain.go @@ -0,0 +1,19 @@ +package iruka + +import ( + "github.com/spesnova/iruka/schema" +) + +func (c *Client) DomainCreate(appIdentity string, opts schema.DomainCreateOpts) (schema.Domain, error) { + var domainRes schema.Domain + return domainRes, c.Post(&domainRes, "/apps/"+appIdentity+"/domains", opts) +} + +func (c *Client) DomainDelete(appIdentity, domainIdentity string) error { + return c.Delete("/apps/" + appIdentity + "/domains/" + domainIdentity) +} + +func (c *Client) DomainList(appIdentity string) ([]schema.Domain, error) { + var domainsRes []schema.Domain + return domainsRes, c.Get(&domainsRes, "/apps/"+appIdentity+"/domains") +} diff --git a/coreos/user-data.yml.erb b/coreos/user-data.yml.erb index 0bde93a..0a44ac5 100644 --- a/coreos/user-data.yml.erb +++ b/coreos/user-data.yml.erb @@ -41,6 +41,26 @@ coreos: [Install] WantedBy=sockets.target + - name: vulcand.service + command: start + enable: true + content: | + [Unit] + Description=Vulcand + After=docker.service + Requires=docker.service + + [Service] + TimeoutStartSec=0 + User=core + ExecStartPre=-/usr/bin/docker kill vulcand + ExecStartPre=-/usr/bin/docker rm vulcand + ExecStartPre=/usr/bin/docker pull mailgun/vulcand:v0.8.0-beta.2 + ExecStart=/usr/bin/docker run --name vulcand -p 80:80 -p 443:443 -p 8182:8182 -p 8181:8181 mailgun/vulcand:v0.8.0-beta.2 /go/bin/vulcand -apiInterface=0.0.0.0 -interface=0.0.0.0 -etcd=http://10.1.42.1:4001 -port=80 -apiPort=8182 + ExecStop=/usr/bin/docker stop vulcand + + [Install] + WantedBy=multi-user.target write_files: - path: /etc/ssh/sshd_config permissions: 0600 diff --git a/docs/api-v1-alpha.md b/docs/api-v1-alpha.md index 5d3c726..cad9352 100644 --- a/docs/api-v1-alpha.md +++ b/docs/api-v1-alpha.md @@ -565,4 +565,156 @@ HTTP/1.1 200 OK ``` +## Domain + +Domain attached to an app on iruka. + +### Attributes + +| Name | Type | Description | Example | +| ------- | ------- | ------- | ------- | +| **created_at** | *date-time* | when domain was created | `"2012-01-01T12:00:00Z"` | +| **id** | *uuid* | unique identifier of domain | `"01234567-89ab-cdef-0123-456789abcdef"` | +| **hostname** | *string* | domain hostname | `"example.com"` | +| **updated_at** | *date-time* | when domain was updated | `"2012-01-01T12:00:00Z"` | + +### Domain Create + +Create a new domain. + +``` +POST /apps/{app_id_or_name}/domains +``` + +#### Optional Parameters + +| Name | Type | Description | Example | +| ------- | ------- | ------- | ------- | +| **hostname** | *string* | domain hostname | `"example.com"` | + + +#### Curl Example + +```bash +$ curl -n -X POST https://.com/api/v1-alpha/apps/$APP_ID_OR_NAME/domains \ + -H "Content-Type: application/json" \ + \ + -d '{ + "hostname": "example.com" +}' +``` + + +#### Response Example + +``` +HTTP/1.1 201 Created +``` + +```json +{ + "created_at": "2012-01-01T12:00:00Z", + "id": "01234567-89ab-cdef-0123-456789abcdef", + "hostname": "example.com", + "updated_at": "2012-01-01T12:00:00Z" +} +``` + +### Domain Delete + +Delete an existing domain. + +``` +DELETE /apps/{app_id_or_name}/domains/{domain_id_or_hostname} +``` + + +#### Curl Example + +```bash +$ curl -n -X DELETE https://.com/api/v1-alpha/apps/$APP_ID_OR_NAME/domains/$DOMAIN_ID_OR_HOSTNAME \ + -H "Content-Type: application/json" \ +``` + + +#### Response Example + +``` +HTTP/1.1 200 OK +``` + +```json +{ + "created_at": "2012-01-01T12:00:00Z", + "id": "01234567-89ab-cdef-0123-456789abcdef", + "hostname": "example.com", + "updated_at": "2012-01-01T12:00:00Z" +} +``` + +### Domain Info + +Info for existing domain. + +``` +GET /apps/{app_id_or_name}/domains/{domain_id_or_hostname} +``` + + +#### Curl Example + +```bash +$ curl -n https://.com/api/v1-alpha/apps/$APP_ID_OR_NAME/domains/$DOMAIN_ID_OR_HOSTNAME +``` + + +#### Response Example + +``` +HTTP/1.1 200 OK +``` + +```json +{ + "created_at": "2012-01-01T12:00:00Z", + "id": "01234567-89ab-cdef-0123-456789abcdef", + "hostname": "example.com", + "updated_at": "2012-01-01T12:00:00Z" +} +``` + +### Domain List + +List existing domains. + +``` +GET /apps/{app_id_or_name}/domains +``` + + +#### Curl Example + +```bash +$ curl -n https://.com/api/v1-alpha/apps/$APP_ID_OR_NAME/domains +``` + + +#### Response Example + +``` +HTTP/1.1 200 OK +``` + +```json +[ + { + "created_at": "2012-01-01T12:00:00Z", + "id": "01234567-89ab-cdef-0123-456789abcdef", + "hostname": "example.com", + "updated_at": "2012-01-01T12:00:00Z" + } +] +``` + + diff --git a/iruka/commands.go b/iruka/commands.go index f3ad8f2..daa54fc 100644 --- a/iruka/commands.go +++ b/iruka/commands.go @@ -9,6 +9,9 @@ var Commands = []cli.Command{ cmdCreate, cmdConfig, cmdDestroy, + cmdDomains, + cmdDomainAdd, + cmdDomainRemove, cmdPs, cmdDeploy, cmdOpen, diff --git a/iruka/domain-add.go b/iruka/domain-add.go new file mode 100644 index 0000000..91cf694 --- /dev/null +++ b/iruka/domain-add.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + "os" + + "github.com/codegangsta/cli" + + "github.com/spesnova/iruka/schema" +) + +var cmdDomainAdd = cli.Command{ + Name: "domain-add", + Usage: "Add a custom domain to the app", + Description: ` + $ iruka domain- add + +EXAMPLE: + + $ iruka domain-add example.com + Adding example.com to example... done +`, + Action: runDomainAdd, +} + +func runDomainAdd(c *cli.Context) { + if len(c.Args()) < 1 { + cli.ShowCommandHelp(c, "add") + os.Exit(1) + } + + appIdentity := getAppIdentity(c) + hostname := c.Args().First() + opts := schema.DomainCreateOpts{ + Hostname: hostname, + } + + fmt.Printf("Adding %s to %s...", hostname, appIdentity) + _, err := client.DomainCreate(appIdentity, opts) + + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + fmt.Println("done") +} diff --git a/iruka/domain-remove.go b/iruka/domain-remove.go new file mode 100644 index 0000000..c7aa6d4 --- /dev/null +++ b/iruka/domain-remove.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "os" + + "github.com/codegangsta/cli" +) + +var cmdDomainRemove = cli.Command{ + Name: "domain-remove", + Usage: "Remove a custom domain from the app", + Description: ` + $ iruka domain-remove + +EXAMPLE: + + $ iruka domain-remove example.com + Removing example.com from example... done +`, + Action: runDomainRemove, +} + +func runDomainRemove(c *cli.Context) { + appIdentity := getAppIdentity(c) + hostname := c.Args().First() + + fmt.Printf("Removing %s from %s...", hostname, appIdentity) + err := client.DomainDelete(appIdentity, hostname) + + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + fmt.Println("done") +} diff --git a/iruka/domains.go b/iruka/domains.go new file mode 100644 index 0000000..4326d93 --- /dev/null +++ b/iruka/domains.go @@ -0,0 +1,42 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/codegangsta/cli" +) + +var cmdDomains = cli.Command{ + Name: "domains", + Usage: "List custom domains for the app", + Description: ` + $ iruka domains + +EXAMPLE: + + $ iruka domains + example.com +`, + Action: runDomains, +} + +func runDomains(c *cli.Context) { + appIdentity := getAppIdentity(c) + domains, err := client.DomainList(appIdentity) + + if err != nil { + fmt.Println("error") + fmt.Println(err) + os.Exit(1) + } + + for _, d := range domains { + var f []string + f = append(f, d.Hostname) + fmt.Fprintln(out, strings.Join(f, "\t")) + } + + out.Flush() +} diff --git a/irukad/controllers/app.go b/irukad/controllers/app.go index 0832593..d288106 100644 --- a/irukad/controllers/app.go +++ b/irukad/controllers/app.go @@ -3,21 +3,24 @@ package controllers import ( "encoding/json" "net/http" + "os" "github.com/gorilla/mux" "github.com/unrolled/render" "github.com/spesnova/iruka/registry" + "github.com/spesnova/iruka/router" "github.com/spesnova/iruka/schema" ) type AppController struct { *registry.Registry *render.Render + *router.Router } -func NewAppController(reg *registry.Registry, ren *render.Render) AppController { - return AppController{reg, ren} +func NewAppController(reg *registry.Registry, ren *render.Render, rou *router.Router) AppController { + return AppController{reg, ren, rou} } func (c *AppController) Create(rw http.ResponseWriter, r *http.Request) { @@ -41,6 +44,43 @@ func (c *AppController) Create(rw http.ResponseWriter, r *http.Request) { return } + dopts := schema.DomainCreateOpts{ + Hostname: app.ID.String() + "." + os.Getenv("DEFAULT_DOMAIN"), + } + + domain, err := c.Registry.CreateDomain(app.ID.String(), dopts) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, "error") + return + } + + ropts := schema.RouteCreateOpts{ + Location: "/.*", + Upstream: app.ID.String(), + } + + route, err := c.Registry.CreateRoute(app.ID.String(), ropts) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, "error") + return + } + + err = c.Router.AddBackend(app.ID.String()) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, "error") + return + } + + err = c.Router.AddRoute(app.ID.String(), domain.Hostname, route.Location) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, "error") + return + } + c.JSON(rw, http.StatusCreated, app) } diff --git a/irukad/controllers/domain.go b/irukad/controllers/domain.go new file mode 100644 index 0000000..f6135f0 --- /dev/null +++ b/irukad/controllers/domain.go @@ -0,0 +1,132 @@ +package controllers + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "github.com/unrolled/render" + + "github.com/spesnova/iruka/registry" + "github.com/spesnova/iruka/router" + "github.com/spesnova/iruka/schema" +) + +type DomainController struct { + reg *registry.Registry + *render.Render + rou *router.Router +} + +func NewDomainController(reg *registry.Registry, ren *render.Render, rou *router.Router) DomainController { + return DomainController{reg, ren, rou} +} + +func (c *DomainController) Create(rw http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + vars := mux.Vars(r) + appIdentity := vars["appIdentity"] + + var opts schema.DomainCreateOpts + err := json.NewDecoder(r.Body).Decode(&opts) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, err.Error()) + } + + app, err := c.reg.App(appIdentity) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, err.Error()) + } + + routes, err := c.reg.RoutesFilteredByApp(appIdentity) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, err.Error()) + } + + for _, route := range routes { + // TODO(dtan4): + // Remove duplicates (most routes are the same) + err := c.rou.AddRoute(app.ID.String(), opts.Hostname, route.Location) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, err.Error()) + } + } + + domain, err := c.reg.CreateDomain(appIdentity, opts) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, err.Error()) + } + + c.JSON(rw, http.StatusCreated, domain) +} + +func (c *DomainController) Delete(rw http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + appIdentity := vars["appIdentity"] + identity := vars["identity"] + + app, err := c.reg.App(appIdentity) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, err.Error()) + } + + domain, err := c.reg.DomainFilteredByApp(appIdentity, identity) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, err.Error()) + } + + err = c.rou.RemoveRoute(app.ID.String(), domain.Hostname) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, err.Error()) + } + + _, err = c.reg.DestroyDomain(identity) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, err.Error()) + } + + c.JSON(rw, http.StatusAccepted, domain) +} + +func (c *DomainController) Info(rw http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + appIdentity := vars["appIdentity"] + identity := vars["identity"] + + domain, err := c.reg.DomainFilteredByApp(appIdentity, identity) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, err.Error()) + } + + c.JSON(rw, http.StatusOK, domain) +} + +func (c *DomainController) List(rw http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + appIdentity := vars["appIdentity"] + + domains, err := c.reg.DomainsFilteredByApp(appIdentity) + + if err != nil { + c.JSON(rw, http.StatusInternalServerError, err.Error()) + return + } + + if domains == nil { + c.JSON(rw, http.StatusOK, []schema.Domain{}) + return + } + + c.JSON(rw, http.StatusOK, domains) +} diff --git a/irukad/iruka.go b/irukad/iruka.go index ce25acf..5a5315e 100644 --- a/irukad/iruka.go +++ b/irukad/iruka.go @@ -11,13 +11,16 @@ import ( "github.com/spesnova/iruka/agent" "github.com/spesnova/iruka/irukad/controllers" "github.com/spesnova/iruka/registry" + "github.com/spesnova/iruka/router" "github.com/spesnova/iruka/scheduler" ) func main() { // Registry - machines := registry.DefaultMachines - reg := registry.NewRegistry(machines, registry.DefaultKeyPrefix) + reg := registry.NewRegistry(registry.DefaultMachines, registry.DefaultKeyPrefix) + + // Sub-domain router + rou := router.NewRouter(router.DefaultMachines, router.DefaultKeyPrefix) // Scheduler url := scheduler.DefaultAPIURL @@ -28,19 +31,20 @@ func main() { if machine == "" { log.Fatal("IRUKA_MACHINE is required, but missing") } - age := agent.NewAgent(agent.DefaultHost, machine, reg) + age := agent.NewAgent(agent.DefaultHost, machine, reg, rou) // Render ren := render.New() // Controllers - appController := controllers.NewAppController(reg, ren) + appController := controllers.NewAppController(reg, ren, rou) containerController := controllers.NewContainerController(reg, ren, sch) configVarsController := controllers.NewConfigVarsController(reg, ren) + domainController := controllers.NewDomainController(reg, ren, rou) // Router - rou := mux.NewRouter() - v1rou := rou.PathPrefix("/api/v1-alpha").Subrouter() + muxRou := mux.NewRouter() + v1rou := muxRou.PathPrefix("/api/v1-alpha").Subrouter() // App Resource v1rou.Path("/apps").Methods("POST").HandlerFunc(appController.Create) @@ -62,13 +66,19 @@ func main() { v1subrou.Path("/config-vars").Methods("GET").HandlerFunc(configVarsController.Info) v1subrou.Path("/config-vars").Methods("PATCH").HandlerFunc(configVarsController.Update) + // Domain Resource + v1subrou.Path("/domains").Methods("POST").HandlerFunc(domainController.Create) + v1subrou.Path("/domains/{identity}").Methods("DELETE").HandlerFunc(domainController.Delete) + v1subrou.Path("/domains/{identity}").Methods("GET").HandlerFunc(domainController.Info) + v1subrou.Path("/domains").Methods("GET").HandlerFunc(domainController.List) + // Middleware stack n := negroni.New( negroni.NewRecovery(), negroni.NewLogger(), ) - n.UseHandler(rou) + n.UseHandler(muxRou) go age.Pulse() // Disable retrieving unit state from fleet for now diff --git a/registry/domain.go b/registry/domain.go new file mode 100644 index 0000000..6e927c7 --- /dev/null +++ b/registry/domain.go @@ -0,0 +1,192 @@ +package registry + +import ( + "errors" + "path" + "sort" + "time" + + "code.google.com/p/go-uuid/uuid" + + "github.com/spesnova/iruka/schema" +) + +const ( + domainPrefix = "domains" +) + +type Domains []schema.Domain + +// imprement the sort interface +func (d Domains) Len() int { + return len(d) +} + +func (d Domains) Swap(i, j int) { + d[i], d[j] = d[j], d[i] +} + +func (d Domains) Less(i, j int) bool { + return d[i].ID.String() < d[j].ID.String() +} + +func (r *Registry) CreateDomain(appIdentity string, opts schema.DomainCreateOpts) (schema.Domain, error) { + app, err := r.App(appIdentity) + + if err != nil { + return schema.Domain{}, err + } + + if opts.Hostname == "" { + return schema.Domain{}, errors.New("hostname parameter is required, but missing") + } + + id := uuid.NewUUID() + currentTime := time.Now() + domain := schema.Domain{ + ID: id, + AppID: app.ID, + Hostname: opts.Hostname, + CreatedAt: currentTime, + UpdatedAt: currentTime, + } + + j, err := marshal(domain) + + if err != nil { + return schema.Domain{}, err + } + + key := path.Join(r.keyPrefix, domainPrefix, domain.ID.String()) + _, err = r.etcd.Create(key, string(j), 0) + + if err != nil { + return schema.Domain{}, err + } + + return domain, nil +} + +func (r *Registry) DestroyDomain(identity string) (schema.Domain, error) { + domain, err := r.Domain(identity) + + if err != nil { + return schema.Domain{}, err + } + + key := path.Join(r.keyPrefix, domainPrefix, domain.ID.String()) + _, err = r.etcd.Delete(key, true) + + if err != nil { + return schema.Domain{}, errors.New("Failed to delete domain: " + domain.ID.String()) + } + + return domain, nil +} + +func (r *Registry) Domain(identity string) (schema.Domain, error) { + var domain schema.Domain + + domains, err := r.Domains() + + if err != nil { + return domain, err + } + + if uuid.Parse(identity) == nil { + for _, domain := range domains { + if domain.Hostname == identity { + return domain, nil + } + } + } else { + for _, domain := range domains { + if uuid.Equal(domain.ID, uuid.Parse(identity)) { + return domain, nil + } + } + } + + return domain, errors.New("No such domain: " + identity) +} + +func (r *Registry) DomainFilteredByApp(appIdentity, identity string) (schema.Domain, error) { + domain, err := r.Domain(identity) + + if err != nil { + return schema.Domain{}, err + } + + app, err := r.App(appIdentity) + + if err != nil { + return schema.Domain{}, err + } + + if uuid.Equal(domain.AppID, app.ID) { + return domain, nil + } + + return domain, errors.New("No such domain: " + identity) +} + +func (r *Registry) Domains() ([]schema.Domain, error) { + key := path.Join(r.keyPrefix, domainPrefix) + res, err := r.etcd.Get(key, false, true) + + if err != nil { + if isKeyNotFound(err) { + err = nil + } + return nil, err + } + + if len(res.Node.Nodes) == 0 { + return nil, nil + } + + var domains Domains + + for _, node := range res.Node.Nodes { + var domain schema.Domain + err = unmarshal(node.Value, &domain) + + if err != nil { + return nil, err + } + + domains = append(domains, domain) + } + + sort.Sort(sort.Reverse(domains)) + + return domains, nil +} + +func (r *Registry) DomainsFilteredByApp(appIdentity string) ([]schema.Domain, error) { + var domains []schema.Domain + + app, err := r.App(appIdentity) + + if err != nil { + return nil, err + } + + ds, err := r.Domains() + + if err != nil { + return nil, err + } + + if ds == nil { + return nil, nil + } + + for _, d := range ds { + if uuid.Equal(d.AppID, app.ID) { + domains = append(domains, d) + } + } + + return domains, nil +} diff --git a/registry/route.go b/registry/route.go new file mode 100644 index 0000000..985ca7a --- /dev/null +++ b/registry/route.go @@ -0,0 +1,226 @@ +package registry + +import ( + "errors" + "path" + "sort" + "time" + + "code.google.com/p/go-uuid/uuid" + + "github.com/spesnova/iruka/schema" +) + +const ( + routePrefix = "routes" +) + +type Routes []schema.Route + +// imprement the sort interface +func (r Routes) Len() int { + return len(r) +} + +func (r Routes) Swap(i, j int) { + r[i], r[j] = r[j], r[i] +} + +func (r Routes) Less(i, j int) bool { + return r[i].ID.String() < r[j].ID.String() +} + +func (r *Registry) CreateRoute(appIdentity string, opts schema.RouteCreateOpts) (schema.Route, error) { + app, err := r.App(appIdentity) + + if err != nil { + return schema.Route{}, err + } + + if opts.Location == "" { + return schema.Route{}, errors.New("location parameter is required, but missing") + } + + if opts.Upstream == "" { + return schema.Route{}, errors.New("upstream parameter is required, but missing") + } + + id := uuid.NewUUID() + currentTime := time.Now() + route := schema.Route{ + ID: id, + AppID: app.ID, + Location: opts.Location, + Upstream: opts.Upstream, + CreatedAt: currentTime, + UpdatedAt: currentTime, + } + + j, err := marshal(route) + + if err != nil { + return schema.Route{}, err + } + + key := path.Join(r.keyPrefix, routePrefix, route.ID.String()) + + if _, err = r.etcd.Create(key, string(j), 0); err != nil { + return schema.Route{}, err + } + + return route, nil +} + +func (r *Registry) DestroyRoute(identity string) (schema.Route, error) { + route, err := r.Route(identity) + + if err != nil { + return schema.Route{}, err + } + + key := path.Join(r.keyPrefix, routePrefix, route.ID.String()) + _, err = r.etcd.Delete(key, true) + + if err != nil { + return schema.Route{}, errors.New("Failed to delete route: " + route.ID.String()) + } + + return route, nil +} + +func (r *Registry) Route(identity string) (schema.Route, error) { + var route schema.Route + + routes, err := r.Routes() + + if err != nil { + return route, err + } + + if uuid.Parse(identity) == nil { + return route, errors.New("No such route: " + identity) + } else { + for _, route := range routes { + if uuid.Equal(route.ID, uuid.Parse(identity)) { + return route, nil + } + } + } + + return route, errors.New("No such route: " + identity) +} + +func (r *Registry) RouteFilteredByApp(appIdentity, identity string) (schema.Route, error) { + route, err := r.Route(identity) + + if err != nil { + return schema.Route{}, err + } + + app, err := r.App(appIdentity) + + if err != nil { + return schema.Route{}, err + } + + if uuid.Equal(route.AppID, app.ID) { + return route, nil + } + + return route, errors.New("No such route: " + identity) +} + +func (r *Registry) Routes() ([]schema.Route, error) { + key := path.Join(r.keyPrefix, routePrefix) + res, err := r.etcd.Get(key, false, true) + + if err != nil { + if isKeyNotFound(err) { + err = nil + } + return nil, err + } + + if len(res.Node.Nodes) == 0 { + return nil, nil + } + + var routes Routes + + for _, node := range res.Node.Nodes { + var route schema.Route + err = unmarshal(node.Value, &route) + + if err != nil { + return nil, err + } + + routes = append(routes, route) + } + + sort.Sort(sort.Reverse(routes)) + + return routes, nil +} + +func (r *Registry) RoutesFilteredByApp(appIdentity string) ([]schema.Route, error) { + var routes []schema.Route + + app, err := r.App(appIdentity) + + if err != nil { + return nil, err + } + + rs, err := r.Routes() + + if err != nil { + return nil, err + } + + if rs == nil { + return nil, nil + } + + for _, r := range rs { + if uuid.Equal(r.AppID, app.ID) { + routes = append(routes, r) + } + } + + return routes, nil +} + +func (r *Registry) UpdateRoute(appIdentity, identity string, opts schema.RouteUpdateOpts) (schema.Route, error) { + route, err := r.RouteFilteredByApp(appIdentity, identity) + + if err != nil { + return schema.Route{}, err + } + + if opts.ID.String() == "" { + return schema.Route{}, errors.New("id parameter is required, but missing") + } + + if opts.Location != "" { + route.Location = opts.Location + } + + if opts.Upstream != "" { + route.Upstream = opts.Upstream + } + + j, err := marshal(route) + + if err != nil { + return schema.Route{}, err + } + + key := path.Join(r.keyPrefix, routePrefix, identity) + + if _, err := r.etcd.Set(key, string(j), 0); err != nil { + return schema.Route{}, err + } + + return route, nil +} diff --git a/router/router.go b/router/router.go new file mode 100644 index 0000000..95b9112 --- /dev/null +++ b/router/router.go @@ -0,0 +1,188 @@ +package router + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path" + "strings" + + "github.com/coreos/go-etcd/etcd" + + "github.com/spesnova/iruka/schema" +) + +const ( + DefaultKeyPrefix = "/vulcand" + DefaultMachines = "http://localhost:4001" +) + +type Router struct { + etcd etcd.Client + keyPrefix string +} + +func NewRouter(machines, keyPrefix string) *Router { + if os.Getenv("IRUKA_ETCD_MACHINES") != "" { + machines = os.Getenv("IRUKA_ETCD_MACHINES") + } + + if machines == "" { + machines = DefaultMachines + } + + m := strings.Split(machines, ",") + etcdClient := *etcd.NewClient(m) + return &Router{etcdClient, keyPrefix} +} + +// TODO(dtan4): +// Divide by upstream (blue/green) +func (r *Router) AddBackend(name string) error { + backend := schema.VulcandBackend{ + Type: "http", + } + j, err := marshal(backend) + + if err != nil { + return err + } + + key := path.Join(r.keyPrefix, "backends", name, "backend") + _, err = r.etcd.Create(key, string(j), 0) + + if err != nil { + return err + } + + return nil +} + +func (r *Router) RemoveBackend(name string) error { + key := path.Join(r.keyPrefix, "backends", name, "backend") + _, err := r.etcd.Delete(key, true) + + if err != nil { + return err + } + + return nil +} + +// TODO(dtan4): +// Divide by upstream (blue/green) +func (r *Router) AddServer(appID, containerName, url string) error { + server := schema.VulcandServer{ + URL: url, + } + j, err := marshal(server) + + if err != nil { + return err + } + + key := path.Join(r.keyPrefix, "backends", appID, "servers", containerName) + _, err = r.etcd.Create(key, string(j), 0) + + if err != nil { + return err + } + + return nil +} + +func (r *Router) RemoveServer(appID, containerName string) error { + key := path.Join(r.keyPrefix, "backends", appID, "servers", containerName) + _, err := r.etcd.Delete(key, true) + + if err != nil { + return err + } + + return nil +} + +func (r *Router) IsServerExists(appID, containerName string) bool { + key := path.Join(r.keyPrefix, "backends", appID, "servers", containerName) + _, err := r.etcd.Get(key, false, false) + + return err == nil +} + +func (r *Router) AddRoute(name, host, location string) error { + frontend := schema.VulcandFrontend{ + Type: "http", + BackendID: name, + Route: routeString(host, location), + } + j, err := marshal(frontend) + + if err != nil { + return err + } + + key := path.Join(r.keyPrefix, "frontends", fmt.Sprintf("%s-%s", name, host), "frontend") + _, err = r.etcd.Create(key, j, 0) + + if err != nil { + return err + } + + return nil +} + +func (r *Router) RemoveRoute(name, host string) error { + key := path.Join(r.keyPrefix, "frontends", fmt.Sprintf("%s-%s", name, host)) + _, err := r.etcd.Delete(key, true) + + if err != nil { + return err + } + + return nil +} + +func (r *Router) UpdateRoute(name, host, location string) error { + frontend := schema.VulcandFrontend{ + Type: "http", + BackendID: name, + Route: routeString(host, location), + } + j, err := marshal(frontend) + + if err != nil { + return err + } + + key := path.Join(r.keyPrefix, "frontends", name, "frontend") + _, err = r.etcd.Set(key, j, 0) + + if err != nil { + return err + } + + return nil +} + +func routeString(hostname, path string) string { + return fmt.Sprintf("Host(`%s`) && PathRegexp(`%s`)", hostname, path) +} + +func marshal(obj interface{}) (string, error) { + encoded, err := json.Marshal(obj) + if err != nil { + return "", fmt.Errorf("unable to JSON-serialize object: %s", err) + } + + // To print '&' + return string(bytes.Replace(encoded, []byte("\\u0026"), []byte("&"), -1)), nil +} + +func unmarshal(val string, obj interface{}) error { + err := json.Unmarshal([]byte(val), &obj) + if err != nil { + return fmt.Errorf("unable to JSON-deserialize object: %s", err) + } + return nil +} diff --git a/scheduler/container.go b/scheduler/container.go index fc8d401..0ae8fbb 100644 --- a/scheduler/container.go +++ b/scheduler/container.go @@ -69,6 +69,7 @@ func (s *Scheduler) containerToUnitOptions(c schema.Container, cv schema.ConfigV // Service section example: // // [Service] + // TimeoutStartSec=0 // ExecStartPre=/usr/bin/docker pull quay.io/spesnova/example:latest // ExecStartPre=-/usr/bin/docker kill hello.web.a888f12a-0806-11e5-b898-5cf93896cc38 // ExecStartPre=-/usr/bin/docker rm hello.web.a888f12a-0806-11e5-b898-5cf93896cc38 @@ -81,6 +82,11 @@ func (s *Scheduler) containerToUnitOptions(c schema.Container, cv schema.ConfigV // ExecStop=/usr/bin/docker rm -f hello.web.a888f12a-0806-11e5-b898-5cf93896cc38 // Restart=on-failure // + &fleet.UnitOption{ + Section: "Service", + Name: "TimeoutStartSec", + Value: "0", + }, &fleet.UnitOption{ Section: "Service", Name: "ExecStartPre", diff --git a/schema/domain.go b/schema/domain.go new file mode 100644 index 0000000..ba8bf8d --- /dev/null +++ b/schema/domain.go @@ -0,0 +1,19 @@ +package schema + +import ( + "time" + + "code.google.com/p/go-uuid/uuid" +) + +type Domain struct { + ID uuid.UUID `json:"id"` + AppID uuid.UUID `json:"app_id"` + Hostname string `json:"hostname"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DomainCreateOpts struct { + Hostname string `json:"hostname"` +} diff --git a/schema/route.go b/schema/route.go new file mode 100644 index 0000000..ea10378 --- /dev/null +++ b/schema/route.go @@ -0,0 +1,27 @@ +package schema + +import ( + "time" + + "code.google.com/p/go-uuid/uuid" +) + +type Route struct { + ID uuid.UUID `json:"id"` + AppID uuid.UUID `json:"app_id"` + Location string `json:"location"` + Upstream string `json:"upstream"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RouteCreateOpts struct { + Location string `json:"location"` + Upstream string `json:"upstream"` +} + +type RouteUpdateOpts struct { + ID uuid.UUID `json:"id"` + Location string `json:"location"` + Upstream string `json:"upstream"` +} diff --git a/schema/schema.json b/schema/schema.json index 797bfe2..cdafcc0 100644 --- a/schema/schema.json +++ b/schema/schema.json @@ -467,6 +467,124 @@ } } }, + "domain": { + "$schema": "http://json-schema.org/draft-04/hyper-schema", + "title": "Domain", + "description": "Domain attached to an app on iruka.", + "stability": "prototype", + "strictProperties": true, + "type": [ + "object" + ], + "definitions": { + "identity": { + "anyOf": [ + { + "$ref": "#/definitions/domain/definitions/id" + }, + { + "$ref": "#/definitions/domain/definitions/hostname" + } + ] + }, + "id": { + "description": "unique identifier of domain", + "example": "01234567-89ab-cdef-0123-456789abcdef", + "readOnly": true, + "format": "uuid", + "type": [ + "string" + ] + }, + "app_id": { + "description": "unique identifier of app the container is belong to", + "example": "01234567-89ab-cdef-0123-456789abcdef", + "readOnly": true, + "format": "uuid", + "type": [ + "string" + ] + }, + "hostname": { + "description": "domain hostname", + "example": "example.com", + "readOnly": true, + "type": [ + "string" + ] + }, + "created_at": { + "description": "when domain was created", + "example": "2012-01-01T12:00:00Z", + "format": "date-time", + "type": [ + "string" + ] + }, + "updated_at": { + "description": "when domain was updated", + "example": "2012-01-01T12:00:00Z", + "format": "date-time", + "type": [ + "string" + ] + } + }, + "links": [ + { + "description": "Create a new domain.", + "href": "/apps/{(%23%2Fdefinitions%2Fapp%2Fdefinitions%2Fidentity)}/domains", + "method": "POST", + "rel": "create", + "schema": { + "properties": { + "hostname": { + "$ref": "#/definitions/domain/definitions/hostname" + } + }, + "type": [ + "object" + ] + }, + "title": "Create" + }, + { + "description": "Delete an existing domain.", + "href": "/apps/{(%23%2Fdefinitions%2Fapp%2Fdefinitions%2Fidentity)}/domains/{(%23%2Fdefinitions%2Fdomain%2Fdefinitions%2Fidentity)}", + "method": "DELETE", + "rel": "destroy", + "title": "Delete" + }, + { + "description": "Info for existing domain.", + "href": "/apps/{(%23%2Fdefinitions%2Fapp%2Fdefinitions%2Fidentity)}/domains/{(%23%2Fdefinitions%2Fdomain%2Fdefinitions%2Fidentity)}", + "method": "GET", + "rel": "self", + "title": "Info" + }, + { + "description": "List existing domains.", + "href": "/apps/{(%23%2Fdefinitions%2Fapp%2Fdefinitions%2Fidentity)}/domains", + "method": "GET", + "rel": "instances", + "title": "List" + } + ], + "properties": { + "created_at": { + "$ref": "#/definitions/domain/definitions/created_at" + }, + "id": { + "$ref": "#/definitions/domain/definitions/id" + }, + "hostname": { + "$ref": "#/definitions/domain/definitions/hostname" + }, + "updated_at": { + "$ref": "#/definitions/domain/definitions/updated_at" + } + } + }, "error": { "$schema": "http://json-schema.org/draft-04/hyper-schema", "title": "Errors", @@ -531,6 +649,9 @@ "container": { "$ref": "#/definitions/container" }, + "domain": { + "$ref": "#/definitions/domain" + }, "error": { "$ref": "#/definitions/error" } diff --git a/schema/schemata/domain.yml b/schema/schemata/domain.yml new file mode 100644 index 0000000..d3bfd67 --- /dev/null +++ b/schema/schemata/domain.yml @@ -0,0 +1,84 @@ +--- +"$schema": http://json-schema.org/draft-04/hyper-schema +title: Domain +description: Domain attached to an app on iruka. +stability: prototype +strictProperties: true +type: +- object +definitions: + identity: + anyOf: + - "$ref": "/schemata/domain#/definitions/id" + - "$ref": "/schemata/domain#/definitions/hostname" + id: + description: unique identifier of domain + example: 01234567-89ab-cdef-0123-456789abcdef + readOnly: true + format: uuid + type: + - string + app_id: + description: unique identifier of app the container is belong to + example: 01234567-89ab-cdef-0123-456789abcdef + readOnly: true + format: uuid + type: + - string + hostname: + description: domain hostname + example: example.com + readOnly: true + type: + - string + created_at: + description: when domain was created + example: '2012-01-01T12:00:00Z' + format: date-time + type: + - string + updated_at: + description: when domain was updated + example: '2012-01-01T12:00:00Z' + format: date-time + type: + - string +links: +- description: Create a new domain. + href: "/apps/{(%2Fschemata%2Fapp%23%2Fdefinitions%2Fidentity)}/domains" + method: POST + rel: create + schema: + properties: { + "hostname": { + "$ref": "/schemata/domain#/definitions/hostname" + } + } + type: + - object + title: Create +- description: Delete an existing domain. + href: "/apps/{(%2Fschemata%2Fapp%23%2Fdefinitions%2Fidentity)}/domains/{(%2Fschemata%2Fdomain%23%2Fdefinitions%2Fidentity)}" + method: DELETE + rel: destroy + title: Delete +- description: Info for existing domain. + href: "/apps/{(%2Fschemata%2Fapp%23%2Fdefinitions%2Fidentity)}/domains/{(%2Fschemata%2Fdomain%23%2Fdefinitions%2Fidentity)}" + method: GET + rel: self + title: Info +- description: List existing domains. + href: "/apps/{(%2Fschemata%2Fapp%23%2Fdefinitions%2Fidentity)}/domains" + method: GET + rel: instances + title: List +properties: + created_at: + "$ref": "/schemata/domain#/definitions/created_at" + id: + "$ref": "/schemata/domain#/definitions/id" + hostname: + "$ref": "/schemata/domain#/definitions/hostname" + updated_at: + "$ref": "/schemata/domain#/definitions/updated_at" +id: schemata/domain diff --git a/schema/vulcand.go b/schema/vulcand.go new file mode 100644 index 0000000..3b568df --- /dev/null +++ b/schema/vulcand.go @@ -0,0 +1,15 @@ +package schema + +type VulcandBackend struct { + Type string `json:"Type"` +} + +type VulcandServer struct { + URL string `json:"URL"` +} + +type VulcandFrontend struct { + Type string `json:"Type"` + BackendID string `json:"BackendId"` + Route string `json:"Route"` +} diff --git a/script/build b/script/build index 94347dd..775fc11 100755 --- a/script/build +++ b/script/build @@ -15,10 +15,10 @@ fi echo "==> Generating docs..." cd $BASE_DIRECTORY -bin/prmd combine \ +bundle exec prmd combine \ --meta schema/meta.yml \ schema/schemata/ > schema/schema.json -bin/prmd doc \ +bundle exec prmd doc \ --prepend docs/api-v1-alpha-header.md \ schema/schema.json > docs/api-v1-alpha.md echo " Successfully generated"