diff --git a/.dockerignore b/.dockerignore index 6e43c2a9..79a796af 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ ./bin +./build ./.dapper ./dist ./.trash-cache diff --git a/.gitignore b/.gitignore index d43bb90f..ea633385 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /.dapper /bin +/build +/dist *.swp /.trash-cache diff --git a/Dockerfile.dapper b/Dockerfile.dapper index eb47c9b6..167173dc 100644 --- a/Dockerfile.dapper +++ b/Dockerfile.dapper @@ -1,13 +1,15 @@ -FROM golang:1.6 +FROM golang:1.6.2 +RUN apt-get update && \ + apt-get install -y xz-utils zip rsync RUN go get github.com/rancher/trash RUN go get github.com/golang/lint/golint RUN curl -sL https://get.docker.com/builds/Linux/x86_64/docker-1.9.1 > /usr/bin/docker && \ chmod +x /usr/bin/docker ENV PATH /go/bin:$PATH ENV DAPPER_SOURCE /go/src/github.com/rancher/cli -ENV DAPPER_OUTPUT bin +ENV DAPPER_OUTPUT bin build/bin dist ENV DAPPER_DOCKER_SOCKET true -ENV DAPPER_ENV TAG REPO +ENV DAPPER_ENV TAG REPO GOOS CROSS ENV GO15VENDOREXPERIMENT 1 ENV TRASH_CACHE ${DAPPER_SOURCE}/.trash-cache WORKDIR ${DAPPER_SOURCE} diff --git a/README.md b/README.md index 699eca6a..2a57aac4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ -cli -======== +Rancher CLI +=========== -A microservice that does micro things. +It takes arguments and outputs text. Trust me, this is going to be great! + +Coming Summer of '16 ## Building @@ -10,7 +12,7 @@ A microservice that does micro things. ## Running -`./bin/cli` +`./bin/rancher` ## License Copyright (c) 2014-2016 [Rancher Labs, Inc.](http://rancher.com) diff --git a/cmd/catalog.go b/cmd/catalog.go new file mode 100644 index 00000000..6041b43e --- /dev/null +++ b/cmd/catalog.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/rancher/go-rancher/catalog" + "github.com/urfave/cli" +) + +func CatalogCommand() cli.Command { + return cli.Command{ + Name: "catalog", + Usage: "Operations with catalogs", + Action: catalogLs, + Flags: []cli.Flag{}, + } +} + +type CatalogData struct { + ID string + Template catalog.Template +} + +func catalogLs(ctx *cli.Context) error { + config, err := lookupConfig(ctx) + if err != nil { + return err + } + + c, err := GetClient(ctx) + if err != nil { + return err + } + + proj, err := GetEnvironment(config.Environment, c) + if err != nil { + return err + } + + cc, err := GetCatalogClient(ctx) + if err != nil { + return err + } + + envData := NewEnvData(*proj) + envFilter := "" + switch envData.Orchestration { + case "Kubernetes": + envFilter = "kubernetes" + case "Swarm": + envFilter = "swarm" + case "Mesos": + envFilter = "mesos" + } + + collection, err := cc.Template.List(nil) + if err != nil { + return err + } + + writer := NewTableWriter([][]string{ + {"NAME", "Template.Name"}, + {"CATEGORY", "Template.Category"}, + {"ID", "ID"}, + }, ctx) + defer writer.Close() + + for _, item := range collection.Data { + if item.TemplateBase != envFilter { + continue + } + if item.Category == "System" { + continue + } + writer.Write(CatalogData{ + ID: templateID(item), + Template: item, + }) + } + + return writer.Err() +} + +func templateID(template catalog.Template) string { + parts := strings.SplitN(template.Path, "/", 2) + if len(parts) != 2 { + return template.Name + } + + first := parts[0] + second := parts[1] + version := template.DefaultVersion + + parts = strings.SplitN(parts[1], "*", 2) + if len(parts) == 2 { + second = parts[1] + } + + if version == "" { + return fmt.Sprintf("%s/%s", first, second) + } + return fmt.Sprintf("%s/%s:%s", first, second, version) +} + +func GetCatalogClient(ctx *cli.Context) (*catalog.RancherClient, error) { + config, err := lookupConfig(ctx) + if err != nil { + return nil, err + } + + idx := strings.LastIndex(config.URL, "/v1") + if idx == -1 { + return nil, fmt.Errorf("Invalid URL %s, must contain /v1", config.URL) + } + + url := config.URL[:idx] + "/v1-catalog/schemas" + return catalog.NewRancherClient(&catalog.ClientOpts{ + Url: url, + }) +} diff --git a/cmd/common.go b/cmd/common.go new file mode 100644 index 00000000..eb2683a8 --- /dev/null +++ b/cmd/common.go @@ -0,0 +1,318 @@ +package cmd + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "syscall" + "text/template" + + "github.com/Sirupsen/logrus" + "github.com/docker/docker/pkg/namesgenerator" + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +var ( + errNoEnv = errors.New("Failed to find the current environment") +) + +func GetRawClient(ctx *cli.Context) (*client.RancherClient, error) { + config, err := lookupConfig(ctx) + if err != nil { + return nil, err + } + idx := strings.LastIndex(config.URL, "/v1") + if idx == -1 { + return nil, fmt.Errorf("Invalid URL %s, must contain /v1", config.URL) + } + + return client.NewRancherClient(&client.ClientOpts{ + Url: config.URL[:idx] + "/v1", + AccessKey: config.AccessKey, + SecretKey: config.SecretKey, + }) +} + +func lookupConfig(ctx *cli.Context) (Config, error) { + path := ctx.GlobalString("config") + if path == "" { + path = os.ExpandEnv("${HOME}/.rancher/cli.json") + } + + config, err := LoadConfig(path) + if err != nil { + return config, err + } + + url := ctx.GlobalString("url") + accessKey := ctx.GlobalString("access-key") + secretKey := ctx.GlobalString("secret-key") + envName := ctx.GlobalString("environment") + + if url != "" { + config.URL = url + } + if accessKey != "" { + config.AccessKey = accessKey + } + if secretKey != "" { + config.SecretKey = secretKey + } + if envName != "" { + config.Environment = envName + } + + if config.URL == "" { + return config, fmt.Errorf("RANCHER_URL environment or --url is not set, run `config`") + } + + return config, nil +} + +func GetClient(ctx *cli.Context) (*client.RancherClient, error) { + config, err := lookupConfig(ctx) + if err != nil { + return nil, err + } + + url, err := config.EnvironmentURL() + if err != nil { + return nil, err + } + + return client.NewRancherClient(&client.ClientOpts{ + Url: url + "/schemas", + AccessKey: config.AccessKey, + SecretKey: config.SecretKey, + }) +} + +func GetEnvironment(def string, c *client.RancherClient) (*client.Project, error) { + resp, err := c.Project.List(nil) + if err != nil { + return nil, err + } + + if len(resp.Data) == 0 { + return nil, errNoEnv + } + + if len(resp.Data) == 1 { + return &resp.Data[0], nil + } + + if def == "" { + names := []string{} + for _, p := range resp.Data { + names = append(names, fmt.Sprintf("%s(%s)", p.Name, p.Id)) + } + + idx := selectFromList("Environments:", names) + return &resp.Data[idx], nil + } + + return LookupEnvironment(c, def) +} + +func LookupEnvironment(c *client.RancherClient, name string) (*client.Project, error) { + env, err := Lookup(c, name, "account") + if err != nil { + return nil, err + } + if env.Type != "project" { + return nil, fmt.Errorf("Failed to find environment: %s", name) + } + return c.Project.ById(env.Id) +} + +func GetOrCreateDefaultStack(c *client.RancherClient, name string) (*client.Environment, error) { + if name == "" { + name = "Default" + } + + resp, err := c.Environment.List(&client.ListOpts{ + Filters: map[string]interface{}{ + "name": name, + "removed_null": 1, + }, + }) + if err != nil { + return nil, err + } + + if len(resp.Data) > 0 { + return &resp.Data[0], nil + } + + return c.Environment.Create(&client.Environment{ + Name: name, + }) +} + +func getHostByHostname(c *client.RancherClient, name string) (client.ResourceCollection, error) { + var result client.ResourceCollection + allHosts, err := c.Host.List(nil) + if err != nil { + return result, err + } + + for _, host := range allHosts.Data { + if host.Hostname == name { + result.Data = append(result.Data, host.Resource) + } + } + + return result, nil +} + +func RandomName() string { + return strings.Replace(namesgenerator.GetRandomName(0), "_", "-", -1) +} + +func getServiceByName(c *client.RancherClient, name string) (client.ResourceCollection, error) { + var result client.ResourceCollection + env, serviceName, err := ParseName(c, name) + + services, err := c.Service.List(&client.ListOpts{ + Filters: map[string]interface{}{ + "environmentId": env.Id, + "name": serviceName, + }, + }) + if err != nil { + return result, err + } + + for _, service := range services.Data { + result.Data = append(result.Data, service.Resource) + } + + return result, nil +} + +func Lookup(c *client.RancherClient, name string, types ...string) (*client.Resource, error) { + var byName *client.Resource + + for _, schemaType := range types { + var resource client.Resource + if err := c.ById(schemaType, name, &resource); !client.IsNotFound(err) && err != nil { + return nil, err + } else if err == nil && resource.Id == name { // The ID check is because of an oddity in the id obfuscation + return &resource, nil + } + + var collection client.ResourceCollection + if err := c.List(schemaType, &client.ListOpts{ + Filters: map[string]interface{}{ + "name": name, + "removed_null": 1, + }, + }, &collection); err != nil { + return nil, err + } + + if len(collection.Data) > 1 { + ids := []string{} + for _, data := range collection.Data { + ids = append(ids, data.Id) + } + return nil, fmt.Errorf("Multiple resources of type %s found for name %s: %v", schemaType, name, ids) + } + + if len(collection.Data) == 0 { + var err error + // Per type specific logic + switch schemaType { + case "host": + collection, err = getHostByHostname(c, name) + case "service": + collection, err = getServiceByName(c, name) + } + if err != nil { + return nil, err + } + } + + if len(collection.Data) == 0 { + continue + } + + if byName != nil { + return nil, fmt.Errorf("Multiple resources named %s: %s:%s, %s:%s", name, collection.Data[0].Type, + collection.Data[0].Id, byName.Type, byName.Id) + } + + byName = &collection.Data[0] + } + + if byName == nil { + return nil, fmt.Errorf("Not found: %s", name) + } + + return byName, nil +} + +func appendTabDelim(buf *bytes.Buffer, value string) { + if buf.Len() == 0 { + buf.WriteString(value) + } else { + buf.WriteString("\t") + buf.WriteString(value) + } +} + +func SimpleFormat(values [][]string) (string, string) { + headerBuffer := bytes.Buffer{} + valueBuffer := bytes.Buffer{} + for _, v := range values { + appendTabDelim(&headerBuffer, v[0]) + if strings.Contains(v[1], "{{") { + appendTabDelim(&valueBuffer, v[1]) + } else { + appendTabDelim(&valueBuffer, "{{."+v[1]+"}}") + } + } + + headerBuffer.WriteString("\n") + valueBuffer.WriteString("\n") + + return headerBuffer.String(), valueBuffer.String() +} + +func errorWrapper(f func(*cli.Context) error) func(*cli.Context) error { + return func(ctx *cli.Context) error { + if err := f(ctx); err != nil { + logrus.Fatal(err) + } + return nil + } +} + +func printTemplate(out io.Writer, templateContent string, obj interface{}) error { + funcMap := map[string]interface{}{ + "endpoint": FormatEndpoint, + "ips": FormatIPAddresses, + "json": FormatJSON, + } + tmpl, err := template.New("").Funcs(funcMap).Parse(templateContent) + if err != nil { + return err + } + + return tmpl.Execute(out, obj) +} + +func processExitCode(err error) error { + if exitErr, ok := err.(*exec.ExitError); ok { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { + os.Exit(status.ExitStatus()) + } + } + + return err +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 00000000..ff4326e5 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "bufio" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +type Config struct { + AccessKey string `json:"accessKey"` + SecretKey string `json:"secretKey"` + URL string `json:"url"` + Environment string `json:"environment"` + Path string `json:"path,omitempty"` +} + +func (c Config) EnvironmentURL() (string, error) { + projectID := c.Environment + if projectID == "" || !strings.HasPrefix(projectID, "1a") { + rancherClient, err := client.NewRancherClient(&client.ClientOpts{ + Url: c.URL, + AccessKey: c.AccessKey, + SecretKey: c.SecretKey, + }) + if err != nil { + return "", err + } + project, err := GetEnvironment(c.Environment, rancherClient) + if err != nil { + return "", err + } + projectID = project.Id + } + + idx := strings.LastIndex(c.URL, "/v1") + if idx == -1 { + return "", fmt.Errorf("Invalid URL %s, must contain /v1", c.URL) + } + + url := c.URL[:idx] + "/v1/projects/" + projectID + "/schemas" + return url, nil +} + +func (c Config) Write() error { + err := os.MkdirAll(path.Dir(c.Path), 0700) + if err != nil { + return err + } + + logrus.Infof("Saving config to %s", c.Path) + p := c.Path + c.Path = "" + output, err := os.Create(p) + if err != nil { + return err + } + defer output.Close() + + return json.NewEncoder(output).Encode(c) +} + +func LoadConfig(path string) (Config, error) { + config := Config{ + Path: path, + } + + content, err := ioutil.ReadFile(path) + if os.IsNotExist(err) { + return config, nil + } else if err != nil { + return config, err + } + + err = json.Unmarshal(content, &config) + config.Path = path + + return config, err +} + +func ConfigCommand() cli.Command { + return cli.Command{ + Name: "config", + Usage: "Setup client configuration", + Action: errorWrapper(configSetup), + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "dump", + Usage: "Dump the effective configuration", + }, + }, + } +} + +func getConfig(reader *bufio.Reader, text, def string) (string, error) { + for { + fmt.Printf("%s [%s]: ", text, def) + input, err := reader.ReadString('\n') + if err != nil { + return "", err + } + input = strings.TrimSpace(input) + + if input != "" { + return input, nil + } + + if input == "" && def != "" { + return def, nil + } + } +} + +func configSetup(ctx *cli.Context) error { + config, err := lookupConfig(ctx) + if err != nil { + return err + } + + if ctx.Bool("dump") { + return json.NewEncoder(os.Stdout).Encode(config) + } + + reader := bufio.NewReader(os.Stdin) + + config.URL, err = getConfig(reader, "URL", config.URL) + if err != nil { + return err + } + + config.AccessKey, err = getConfig(reader, "Access Key", config.AccessKey) + if err != nil { + return err + } + + config.SecretKey, err = getConfig(reader, "Secret Key", config.SecretKey) + if err != nil { + return err + } + + c, err := client.NewRancherClient(&client.ClientOpts{ + Url: config.URL, + AccessKey: config.AccessKey, + SecretKey: config.SecretKey, + }) + if err != nil { + return err + } + + if schema, ok := c.Schemas.CheckSchema("schema"); ok { + // Normalize URL + config.URL = schema.Links["collection"] + } else { + return fmt.Errorf("Failed to find schema URL") + } + + c, err = client.NewRancherClient(&client.ClientOpts{ + Url: config.URL, + AccessKey: config.AccessKey, + SecretKey: config.SecretKey, + }) + if err != nil { + return err + } + + project, err := GetEnvironment("", c) + if err != errNoEnv { + if err != nil { + return err + } + config.Environment = project.Id + } + + return config.Write() +} diff --git a/cmd/container.go b/cmd/container.go new file mode 100644 index 00000000..0e21a156 --- /dev/null +++ b/cmd/container.go @@ -0,0 +1,52 @@ +package cmd + +import "github.com/urfave/cli" + +func ContainerCommand() cli.Command { + return cli.Command{ + Name: "container", + Usage: "Interact with containers", + Action: errorWrapper(containerLs), + Subcommands: []cli.Command{ + cli.Command{ + Name: "ls", + Usage: "list containers", + Action: errorWrapper(containerLs), + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "quiet,q", + Usage: "Only display IDs", + }, + }, + }, + }, + } +} + +func containerLs(ctx *cli.Context) error { + client, err := GetClient(ctx) + if err != nil { + return err + } + + writer := NewTableWriter([][]string{ + {"ID", "Id"}, + {"NAME", "Name"}, + {"STATE", "State"}, + {"CREATED", "Created"}, + {"START COUNT", "StartCount"}, + {"CREATE INDEX", "CreateIndex"}, + }, ctx) + defer writer.Close() + + collection, err := client.Container.List(nil) + if err != nil { + return err + } + + for _, item := range collection.Data { + writer.Write(item) + } + + return writer.Err() +} diff --git a/cmd/docker.go b/cmd/docker.go new file mode 100644 index 00000000..ecdb4db9 --- /dev/null +++ b/cmd/docker.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + + "github.com/Sirupsen/logrus" + "github.com/rancher/go-rancher/client" + "github.com/rancher/rancher-docker-api-proxy" + "github.com/urfave/cli" +) + +func DockerCommand() cli.Command { + return cli.Command{ + Name: "docker", + Usage: "Run docker CLI on a host", + Action: hostDocker, + SkipFlagParsing: true, + } +} + +func hostDocker(ctx *cli.Context) error { + return processExitCode(doDocker(ctx)) +} + +func doDocker(ctx *cli.Context) error { + hostname := ctx.GlobalString("host") + + if hostname == "" { + return fmt.Errorf("--host is required") + } + + c, err := GetClient(ctx) + if err != nil { + return err + } + + return runDocker(hostname, c, ctx.Args()) +} + +func runDockerCommand(hostname string, c *client.RancherClient, command string, args []string) error { + return runDocker(hostname, c, append([]string{command}, args...)) +} + +func runDocker(hostname string, c *client.RancherClient, args []string) error { + return runDockerWithOutput(hostname, c, args, os.Stdout, os.Stderr) +} + +func runDockerWithOutput(hostname string, c *client.RancherClient, args []string, + out, outErr io.Writer) error { + resource, err := Lookup(c, hostname, "host") + if err != nil { + return err + } + + host, err := c.Host.ById(resource.Id) + if err != nil { + return err + } + + state := getHostState(host) + if state != "active" { + return fmt.Errorf("Can not contact host %s in state %s", hostname, state) + } + + tempfile, err := ioutil.TempFile("", "docker-sock") + if err != nil { + return err + } + defer os.Remove(tempfile.Name()) + + if err := tempfile.Close(); err != nil { + return err + } + + dockerHost := "unix://" + tempfile.Name() + proxy := dockerapiproxy.NewProxy(c, host.Id, dockerHost) + if err := proxy.Listen(); err != nil { + return err + } + + go func() { + logrus.Fatal(proxy.Serve()) + }() + + var cmd *exec.Cmd + if len(args) == 1 && args[0] == "--" { + cmd = exec.Command(os.Getenv("SHELL"), args[1:]...) + cmd.Env = append(os.Environ(), "debian_chroot=docker:"+hostname) + } else { + cmd = exec.Command("docker", args...) + cmd.Env = os.Environ() + } + + cmd.Env = append(cmd.Env, "DOCKER_HOST="+dockerHost) + cmd.Stdin = os.Stdin + cmd.Stdout = out + cmd.Stderr = outErr + + return cmd.Run() +} diff --git a/cmd/env.go b/cmd/env.go new file mode 100644 index 00000000..32727389 --- /dev/null +++ b/cmd/env.go @@ -0,0 +1,214 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +func EnvCommand() cli.Command { + return cli.Command{ + Name: "environment", + ShortName: "env", + Usage: "Interact with environments", + Action: errorWrapper(envLs), + Subcommands: []cli.Command{ + cli.Command{ + Name: "ls", + Usage: "list environments", + Action: errorWrapper(envLs), + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "quiet,q", + Usage: "Only display IDs", + }, + cli.StringFlag{ + Name: "format", + Usage: "'json' or Custom format: {{.Id}} {{.Name}", + }, + }, + }, + cli.Command{ + Name: "create", + Usage: "create environment", + Action: errorWrapper(envCreate), + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "orchestration,o", + Usage: "Name", + }, + }, + }, + cli.Command{ + Name: "rm", + Usage: "Remove environment(s) by ID", + Action: errorWrapper(envRm), + }, + cli.Command{ + Name: "update", + Usage: "Update environment", + Action: errorWrapper(envUpdate), + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "orchestration,o", + Usage: "Orchestration framework", + }, + }, + }, + }, + } +} + +type EnvData struct { + ID string + Environment *client.Project + Orchestration string +} + +func NewEnvData(project client.Project) *EnvData { + orch := "Cattle" + + switch { + case project.Swarm: + orch = "Swarm" + case project.Mesos: + orch = "Mesos" + case project.Kubernetes: + orch = "Kubernetes" + } + + return &EnvData{ + ID: project.Id, + Environment: &project, + Orchestration: orch, + } +} + +func envRm(ctx *cli.Context) error { + c, err := GetRawClient(ctx) + if err != nil { + return err + } + + var lastErr error + for _, id := range ctx.Args() { + env, err := Lookup(c, id, "account") + if err != nil { + logrus.Errorf("Failed to delete %s: %v", id, err) + lastErr = err + continue + } + if err := c.Delete(env); err != nil { + logrus.Errorf("Failed to delete %s: %v", id, err) + lastErr = err + continue + } + fmt.Println(env.Id) + } + + return lastErr +} + +func envUpdate(ctx *cli.Context) error { + c, err := GetRawClient(ctx) + if err != nil { + return err + } + + if ctx.NArg() < 1 { + return cli.NewExitError("Environment name/id is required as the first argument", 1) + } + + orch := ctx.String("orchestration") + if orch == "" { + return nil + } + + env, err := LookupEnvironment(c, ctx.Args()[0]) + if err != nil { + return err + } + + data := map[string]interface{}{} + setFields(ctx, data) + + var newEnv client.Project + + err = c.Update("project", &env.Resource, data, &newEnv) + if err != nil { + return err + } + + fmt.Println(env.Id) + return nil +} + +func envCreate(ctx *cli.Context) error { + c, err := GetRawClient(ctx) + if err != nil { + return err + } + + name := RandomName() + if ctx.NArg() > 0 { + name = ctx.Args()[0] + } + + data := map[string]interface{}{ + "name": name, + } + + setFields(ctx, data) + + var newEnv client.Project + if err := c.Create("project", data, &newEnv); err != nil { + return err + } + + fmt.Println(newEnv.Id) + return nil +} + +func setFields(ctx *cli.Context, data map[string]interface{}) { + orch := strings.ToLower(ctx.String("orchestration")) + + data["swarm"] = false + data["kubernetes"] = false + data["mesos"] = false + + if orch == "k8s" { + orch = "kubernetes" + } + + data[orch] = true +} + +func envLs(ctx *cli.Context) error { + c, err := GetRawClient(ctx) + if err != nil { + return err + } + + writer := NewTableWriter([][]string{ + {"ID", "ID"}, + {"NAME", "Environment.Name"}, + {"ORCHESTRATION", "Orchestration"}, + {"STATE", "Environment.State"}, + {"CREATED", "Environment.Created"}, + }, ctx) + defer writer.Close() + + collection, err := c.Project.List(nil) + if err != nil { + return err + } + + for _, item := range collection.Data { + writer.Write(NewEnvData(item)) + } + + return writer.Err() +} diff --git a/cmd/events.go b/cmd/events.go new file mode 100644 index 00000000..391769d7 --- /dev/null +++ b/cmd/events.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/Sirupsen/logrus" + "github.com/rancher/cli/monitor" + "github.com/urfave/cli" +) + +func EventsCommand() cli.Command { + return cli.Command{ + Name: "events", + Usage: "Show services/containers", + Action: events, + Flags: []cli.Flag{ + //cli.StringFlag{ + // Name: "format", + // Usage: "'json' or Custom format: {{.Id}} {{.Name}", + //}, + cli.BoolFlag{ + Name: "reconnect,r", + Usage: "Reconnect on error", + }, + }, + } +} + +func events(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + m := monitor.New(c) + sub := m.Subscribe() + go func() { + if ctx.Bool("reconnect") { + for { + if err := m.Start(); err != nil { + logrus.Error(err) + } + time.Sleep(time.Second) + } + } else { + logrus.Fatal(m.Start()) + } + }() + + for event := range sub.C { + resource, _ := event.Data["resource"].(map[string]interface{}) + state, _ := resource["state"].(string) + name, _ := resource["name"].(string) + + if len(state) > 0 { + message := resource["transitioningMessage"] + if message == nil { + message = "" + } + fmt.Printf("%s %s %s [%s] %v\n", event.ResourceType, event.ResourceID, state, name, message) + } + } + + return nil +} diff --git a/cmd/exec.go b/cmd/exec.go new file mode 100644 index 00000000..a0a991ea --- /dev/null +++ b/cmd/exec.go @@ -0,0 +1,178 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +func ExecCommand() cli.Command { + return cli.Command{ + Name: "exec", + Usage: "Run a command on a container", + SkipFlagParsing: true, + HideHelp: true, + Action: execCommand, + } +} + +func execCommand(ctx *cli.Context) error { + return processExitCode(execCommandInternal(ctx)) +} + +func execCommandInternal(ctx *cli.Context) error { + if isHelp(ctx.Args()) { + return runDockerHelp("exec") + } + + c, err := GetClient(ctx) + if err != nil { + return err + } + + args, hostID, _, err := selectContainer(c, ctx.Args()) + if err != nil { + return err + } + + return runDockerCommand(hostID, c, "exec", args) +} + +func isHelp(args []string) bool { + for _, i := range args { + if i == "--help" { + return true + } + } + + return false +} + +func selectContainer(c *client.RancherClient, args []string) ([]string, string, string, error) { + newArgs := make([]string, len(args)) + copy(newArgs, args) + + name := "" + index := 0 + for i, val := range newArgs { + if !strings.HasPrefix(val, "-") { + name = val + index = i + break + } + } + + if name == "" { + return nil, "", "", fmt.Errorf("Please specify container name as an argument") + } + + resource, err := Lookup(c, name, "container", "service") + if err != nil { + return nil, "", "", err + } + + if _, ok := resource.Links["hosts"]; ok { + hostID, containerID, err := getHostnameAndContainerID(c, resource.Id) + if err != nil { + return nil, "", "", err + } + + newArgs[index] = containerID + return newArgs, hostID, containerID, nil + } + + if _, ok := resource.Links["instances"]; ok { + var instances client.ContainerCollection + if err := c.GetLink(*resource, "instances", &instances); err != nil { + return nil, "", "", err + } + + hostID, containerID, err := getHostnameAndContainerIDFromList(c, instances) + if err != nil { + return nil, "", "", err + } + newArgs[index] = containerID + return newArgs, hostID, containerID, nil + } + + return nil, "", "", nil +} + +func getHostnameAndContainerIDFromList(c *client.RancherClient, containers client.ContainerCollection) (string, string, error) { + if len(containers.Data) == 0 { + return "", "", fmt.Errorf("Failed to find a container") + } + + if len(containers.Data) == 1 { + return containers.Data[0].HostId, containers.Data[0].ExternalId, nil + } + + names := []string{} + for _, container := range containers.Data { + name := "" + if container.Name == "" { + name = container.Id + } else { + name = container.Name + } + names = append(names, fmt.Sprintf("%s (%s)", name, container.PrimaryIpAddress)) + } + + index := selectFromList("Containers:", names) + return containers.Data[index].HostId, containers.Data[index].ExternalId, nil +} + +func selectFromList(header string, choices []string) int { + if header != "" { + fmt.Println(header) + } + + reader := bufio.NewReader(os.Stdin) + selected := -1 + for selected <= 0 || selected > len(choices) { + for i, choice := range choices { + fmt.Printf("[%d] %s\n", i+1, choice) + } + fmt.Print("Select: ") + + text, _ := reader.ReadString('\n') + text = strings.TrimSpace(text) + num, err := strconv.Atoi(text) + if err == nil { + selected = num + } + } + return selected - 1 +} + +func getHostnameAndContainerID(c *client.RancherClient, containerID string) (string, string, error) { + container, err := c.Container.ById(containerID) + if err != nil { + return "", "", err + } + + var hosts client.HostCollection + if err := c.GetLink(container.Resource, "hosts", &hosts); err != nil { + return "", "", err + } + + if len(hosts.Data) != 1 { + return "", "", fmt.Errorf("Failed to find host for container %s", container.Name) + } + + return hosts.Data[0].Id, container.ExternalId, nil +} + +func runDockerHelp(subcommand string) error { + cmd := exec.Command("docker", subcommand, "--help") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/cmd/export.go b/cmd/export.go new file mode 100644 index 00000000..13032816 --- /dev/null +++ b/cmd/export.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "archive/tar" + "fmt" + "io" + "os" + + "github.com/urfave/cli" +) + +func ExportCommand() cli.Command { + return cli.Command{ + Name: "export", + Usage: "Export configuration yml for a service", + Action: exportService, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "output,o", + Usage: "Write to a file, instead of STDOUT", + }, + }, + } +} + +func getOutput(ctx *cli.Context) (io.WriteCloser, error) { + output := ctx.String("output") + if output == "" { + return os.Stdout, nil + } + return os.Create(output) +} + +func exportService(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + if len(ctx.Args()) != 1 { + return fmt.Errorf("One stack name is required as an argument") + } + + resource, err := Lookup(c, ctx.Args()[0], "environment") + if err != nil { + return err + } + + env, err := c.Environment.ById(resource.Id) + if err != nil { + return err + } + + config, err := c.Environment.ActionExportconfig(env, nil) + if err != nil { + return err + } + + output, err := getOutput(ctx) + if err != nil { + return err + } + defer output.Close() + + archive := tar.NewWriter(output) + defer archive.Close() + + if err := addToTar(archive, "docker-compose.yml", config.DockerComposeConfig); err != nil { + return err + } + return addToTar(archive, "rancher-compose.yml", config.RancherComposeConfig) +} + +func addToTar(archive *tar.Writer, name string, stringContent string) error { + if len(stringContent) == 0 { + return nil + } + + content := []byte(stringContent) + err := archive.WriteHeader(&tar.Header{ + Name: name, + Size: int64(len(content)), + Mode: 0644, + Uname: "root", + Gname: "root", + }) + if err != nil { + return err + } + + _, err = archive.Write(content) + return err +} diff --git a/cmd/format.go b/cmd/format.go new file mode 100644 index 00000000..9aa5bbad --- /dev/null +++ b/cmd/format.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/rancher/go-rancher/client" +) + +func FormatEndpoint(data interface{}) string { + dataSlice, ok := data.([]interface{}) + if !ok { + return "" + } + + buf := &bytes.Buffer{} + for _, value := range dataSlice { + dataMap, ok := value.(map[string]interface{}) + if !ok { + return "" + } + + s := fmt.Sprintf("%v:%v", dataMap["ipAddress"], dataMap["port"]) + if buf.Len() == 0 { + buf.WriteString(s) + } else { + buf.WriteString(", ") + buf.WriteString(s) + } + } + + return buf.String() +} + +func FormatIPAddresses(data interface{}) string { + ips, ok := data.([]client.IpAddress) + if !ok { + return "" + } + + ipStrings := []string{} + for _, ip := range ips { + if ip.Address != "" { + ipStrings = append(ipStrings, ip.Address) + } + } + + return strings.Join(ipStrings, ", ") +} + +func FormatJSON(data interface{}) (string, error) { + bytes, err := json.MarshalIndent(data, "", " ") + return string(bytes) + "\n", err +} diff --git a/cmd/host.go b/cmd/host.go new file mode 100644 index 00000000..ed58b1d0 --- /dev/null +++ b/cmd/host.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +func HostCommand() cli.Command { + return cli.Command{ + Name: "hosts", + ShortName: "host", + Usage: "Operations on hosts", + Action: defaultAction, + Subcommands: []cli.Command{ + cli.Command{ + Name: "create", + Usage: "Create a host", + SkipFlagParsing: true, + Action: hostCreate, + }, + }, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "quiet,q", + Usage: "Only display IDs", + }, + cli.StringFlag{ + Name: "format", + Usage: "'json' or Custom format: {{.Id}} {{.Name}", + }, + }, + } +} + +type HostsData struct { + ID string + Host client.Host + State string + IPAddresses []client.IpAddress +} + +func getHostState(host *client.Host) string { + state := host.State + if state == "active" && host.AgentState != "" { + state = host.AgentState + } + return state +} + +func defaultAction(ctx *cli.Context) error { + if ctx.Bool("help") || len(ctx.Args()) > 0 { + cli.ShowAppHelp(ctx) + return nil + } + + return hostLs(ctx) +} + +func hostLs(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + collection, err := c.Host.List(nil) + if err != nil { + return err + } + + knownMachines := map[string]bool{} + for _, host := range collection.Data { + knownMachines[host.PhysicalHostId] = true + } + + machineCollection, err := c.Machine.List(nil) + if err != nil { + return err + } + + for _, machine := range machineCollection.Data { + if knownMachines[machine.Id] { + continue + } + host := client.Host{ + Resource: client.Resource{ + Id: machine.Id, + }, + Hostname: machine.Name, + State: machine.State, + TransitioningMessage: machine.TransitioningMessage, + } + if machine.State == "active" { + host.State = "waiting" + host.TransitioningMessage = "Almost there... Waiting for agent connection" + } + collection.Data = append(collection.Data, host) + } + + writer := NewTableWriter([][]string{ + {"ID", "Host.Id"}, + {"HOSTNAME", "Host.Hostname"}, + {"STATE", "State"}, + {"IP", "{{ips .IPAddresses}}"}, + {"DETAIL", "Host.TransitioningMessage"}, + }, ctx) + + defer writer.Close() + + for _, item := range collection.Data { + ips := client.IpAddressCollection{} + // ignore errors getting IPs, machines don't have them + c.GetLink(item.Resource, "ipAddresses", &ips) + + writer.Write(&HostsData{ + ID: item.Id, + Host: item, + State: getHostState(&item), + IPAddresses: ips.Data, + }) + } + + return writer.Err() +} diff --git a/cmd/host_create.go b/cmd/host_create.go new file mode 100644 index 00000000..3fb27c9a --- /dev/null +++ b/cmd/host_create.go @@ -0,0 +1,297 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "unicode" + + "strings" + + "github.com/Sirupsen/logrus" + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +func toEnv(name string) string { + buf := bytes.Buffer{} + for _, c := range name { + if unicode.IsUpper(c) { + buf.WriteRune('_') + buf.WriteRune(unicode.ToLower(c)) + } else if c == '-' { + buf.WriteRune('_') + } else { + buf.WriteRune(c) + } + } + return strings.ToUpper(buf.String()) +} + +func toAPI(name string) string { + buf := bytes.Buffer{} + upper := false + for _, c := range name { + if c == '-' { + upper = true + } else if upper { + upper = false + buf.WriteRune(unicode.ToUpper(c)) + } else { + buf.WriteRune(c) + } + } + return buf.String() +} + +func toArg(name string) string { + buf := bytes.Buffer{} + for _, c := range name { + if unicode.IsUpper(c) { + buf.WriteRune('-') + buf.WriteRune(unicode.ToLower(c)) + } else { + buf.WriteRune(c) + } + } + return buf.String() +} + +func buildFlag(name string, field client.Field) cli.Flag { + var flag cli.Flag + switch field.Type { + case "bool": + flag = cli.BoolFlag{ + Name: toArg(name), + EnvVar: toEnv(name), + Usage: field.Description, + } + case "array[string]": + fallthrough + case "map[string]": + flag = cli.StringSliceFlag{ + Name: toArg(name), + EnvVar: toEnv(name), + Usage: field.Description, + } + default: + sflag := cli.StringFlag{ + Name: toArg(name), + EnvVar: toEnv(name), + Usage: field.Description, + } + flag = sflag + if field.Default != nil { + sflag.Value = fmt.Sprint(field.Default) + } + } + + return flag +} + +func buildFlags(prefix string, schema client.Schema, schemas *client.Schemas) []cli.Flag { + flags := []cli.Flag{} + for name, field := range schema.ResourceFields { + if !field.Create || name == "name" { + continue + } + + if strings.HasSuffix(name, "Config") { + subSchema := schemas.Schema(name) + driver := strings.TrimSuffix(name, "Config") + flags = append(flags, buildFlags(driver+"-", subSchema, schemas)...) + } else { + if prefix != "" { + name = prefix + name + } + flags = append(flags, buildFlag(name, field)) + } + } + + return flags +} + +func hostCreate(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + machineSchema := c.Schemas.Schema("machine") + flags := buildFlags("", machineSchema, c.Schemas) + drivers := []string{} + + for name := range machineSchema.ResourceFields { + if strings.HasSuffix(name, "Config") { + drivers = append(drivers, strings.TrimSuffix(name, "Config")) + } + } + + hostCommand := HostCommand() + + for i := range hostCommand.Subcommands { + if hostCommand.Subcommands[i].Name == "create" { + hostCommand.Subcommands[i].Flags = append(flags, cli.StringFlag{ + Name: "driver,d", + Usage: "Driver to use: " + strings.Join(drivers, ", "), + }) + hostCommand.Subcommands[i].Action = func(ctx *cli.Context) error { + return hostCreateRun(ctx, c, machineSchema, c.Schemas) + } + hostCommand.Subcommands[i].SkipFlagParsing = false + } + } + + app := cli.NewApp() + app.Flags = []cli.Flag{ + //TODO: remove duplication here + cli.BoolFlag{ + Name: "debug", + Usage: "Debug logging", + }, + cli.StringFlag{ + Name: "config,c", + Usage: "Client configuration file (default ${HOME}/.rancher/cli.json)", + EnvVar: "RANCHER_CLIENT_CONFIG", + }, + cli.StringFlag{ + Name: "environment,env", + Usage: "Environment name or ID", + EnvVar: "RANCHER_ENVIRONMENT", + }, + cli.StringFlag{ + Name: "url", + Usage: "Specify the Rancher API endpoint URL", + EnvVar: "RANCHER_URL", + }, + cli.StringFlag{ + Name: "access-key", + Usage: "Specify Rancher API access key", + EnvVar: "RANCHER_ACCESS_KEY", + }, + cli.StringFlag{ + Name: "secret-key", + Usage: "Specify Rancher API secret key", + EnvVar: "RANCHER_SECRET_KEY", + }, + cli.StringFlag{ + Name: "host", + Usage: "Host used for docker command", + EnvVar: "RANCHER_DOCKER_HOST", + }, + cli.StringFlag{ + Name: "rancher-file,r", + Usage: "Specify an alternate Rancher compose file (default: rancher-compose.yml)", + }, + cli.StringFlag{ + Name: "env-file,e", + Usage: "Specify a file from which to read environment variables", + }, + cli.StringSliceFlag{ + Name: "file,f", + Usage: "Specify one or more alternate compose files (default: docker-compose.yml)", + Value: &cli.StringSlice{}, + EnvVar: "COMPOSE_FILE", + }, + cli.StringFlag{ + Name: "stack,s", + Usage: "Specify an alternate project name (default: directory name)", + }, + cli.BoolFlag{ + Name: "wait,w", + Usage: "Wait for resource to reach resting state", + }, + cli.IntFlag{ + Name: "wait-timeout", + Usage: "Timeout in seconds to wait", + Value: 600, + }, + cli.StringFlag{ + Name: "wait-state", + Usage: "State to wait for (active, healthy, etc)", + }, + } + app.Commands = []cli.Command{ + hostCommand, + } + return app.Run(os.Args) +} + +func hostCreateRun(ctx *cli.Context, c *client.RancherClient, machineSchema client.Schema, schemas *client.Schemas) error { + args := map[string]interface{}{} + driverArgs := map[string]interface{}{} + driver := ctx.String("driver") + + if driver == "" { + return fmt.Errorf("--driver is required") + } + + driverSchema, ok := schemas.CheckSchema(driver + "Config") + if !ok { + return fmt.Errorf("Invalid driver: %s", driver) + } + + for _, name := range ctx.FlagNames() { + schema := machineSchema + destArgs := args + key := name + value := ctx.Generic(name) + + // really dumb way to detect empty values + if str := fmt.Sprint(value); str == "" || str == "[]" { + continue + } + + if strings.HasPrefix(name, driver+"-") { + key = toAPI(strings.TrimPrefix(name, driver+"-")) + schema = driverSchema + destArgs = driverArgs + } + + fieldType := schema.ResourceFields[key].Type + if fieldType == "map[string]" { + mapValue := map[string]string{} + for _, val := range ctx.StringSlice(name) { + parts := strings.SplitN(val, "=", 2) + if len(parts) == 1 { + mapValue[parts[0]] = "" + } else { + mapValue[parts[0]] = parts[1] + } + } + value = mapValue + } + + destArgs[key] = value + } + + args[driver+"Config"] = driverArgs + + names := ctx.Args() + if len(names) == 0 { + names = []string{RandomName()} + } + + w, err := NewWaiter(ctx) + if err != nil { + return err + } + + var lastErr error + for _, name := range names { + args["name"] = name + var machine client.Machine + if err := c.Create("machine", args, &machine); err != nil { + lastErr = err + logrus.Error(err) + } else { + w.Add(machine.Id) + } + } + + if lastErr != nil { + return lastErr + } + + return w.Wait() +} diff --git a/cmd/logs.go b/cmd/logs.go new file mode 100644 index 00000000..a7e5b132 --- /dev/null +++ b/cmd/logs.go @@ -0,0 +1,224 @@ +package cmd + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "sync" + + "golang.org/x/net/context" + + "github.com/Sirupsen/logrus" + "github.com/docker/docker/pkg/stdcopy" + dclient "github.com/docker/engine-api/client" + "github.com/docker/engine-api/types" + "github.com/docker/libcompose/cli/logger" + "github.com/rancher/go-rancher/client" + "github.com/rancher/rancher-docker-api-proxy" + "github.com/urfave/cli" +) + +var loggerFactory = logger.NewColorLoggerFactory() + +func LogsCommand() cli.Command { + return cli.Command{ + Name: "logs", + Usage: "Fetch the logs of a container", + HideHelp: true, + Action: logsCommand, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "follow,f", + Usage: "Follow log output", + }, + cli.IntFlag{ + Name: "tail", + Value: 100, + Usage: "Number of lines to show from the end of the logs", + }, + cli.StringFlag{ + Name: "since", + Usage: "Show logs since timestamp", + }, + //cli.BoolFlag{ + // Name: "details", + // Usage: "Show extra details provided to logs", + //}, + cli.BoolFlag{ + Name: "timestamps,t", + Usage: "Show timestamps", + }, + }, + } +} + +func logsCommand(ctx *cli.Context) error { + wg := sync.WaitGroup{} + + c, err := GetClient(ctx) + if err != nil { + return err + } + + if len(ctx.Args()) == 0 { + return fmt.Errorf("Please pass a container name") + } + + instances, err := resolveContainers(c, ctx.Args()) + if err != nil { + return err + } + + listenSocks := map[string]*dclient.Client{} + for _, i := range instances { + if i.ExternalId == "" || i.HostId == "" { + continue + } + + if dockerClient, ok := listenSocks[i.HostId]; ok { + wg.Add(1) + go func(dockerClient *dclient.Client, i client.Instance) { + doLog(len(instances) <= 1, ctx, i, dockerClient) + wg.Done() + }(dockerClient, i) + continue + } + + resource, err := Lookup(c, i.HostId, "host") + if err != nil { + return err + } + + host, err := c.Host.ById(resource.Id) + if err != nil { + return err + } + + state := getHostState(host) + if state != "active" { + logrus.Errorf("Can not contact host %s in state %s", i.HostId, state) + continue + } + + tempfile, err := ioutil.TempFile("", "docker-sock") + if err != nil { + return err + } + defer os.Remove(tempfile.Name()) + + if err := tempfile.Close(); err != nil { + return err + } + + dockerHost := "unix://" + tempfile.Name() + proxy := dockerapiproxy.NewProxy(c, host.Id, dockerHost) + if err := proxy.Listen(); err != nil { + return err + } + + go func() { + logrus.Fatal(proxy.Serve()) + }() + + dockerClient, err := dclient.NewClient(dockerHost, "", nil, nil) + if err != nil { + logrus.Errorf("Failed to connect to host %s: %v", i.HostId, err) + continue + } + + listenSocks[i.HostId] = dockerClient + + wg.Add(1) + go func(dockerClient *dclient.Client, i client.Instance) { + doLog(len(instances) <= 1, ctx, i, dockerClient) + wg.Done() + }(dockerClient, i) + } + + wg.Wait() + return nil +} + +func doLog(single bool, ctx *cli.Context, instance client.Instance, dockerClient *dclient.Client) error { + c, err := dockerClient.ContainerInspect(context.Background(), instance.ExternalId) + if err != nil { + return err + } + + options := types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Since: ctx.String("since"), + Timestamps: ctx.Bool("timestamps"), + Follow: ctx.Bool("follow"), + Tail: ctx.String("tail"), + //Details: ctx.Bool("details"), + } + responseBody, err := dockerClient.ContainerLogs(context.Background(), c.ID, options) + if err != nil { + return err + } + defer responseBody.Close() + + if c.Config.Tty { + _, err = io.Copy(os.Stdout, responseBody) + } else if single { + _, err = stdcopy.StdCopy(os.Stdout, os.Stderr, responseBody) + } else { + l := loggerFactory.Create(instance.Name) + _, err = stdcopy.StdCopy(writerFunc(l.Out), writerFunc(l.Err), responseBody) + } + return err +} + +type writerFunc func(p []byte) + +func (f writerFunc) Write(p []byte) (n int, err error) { + f(p) + return len(p), nil +} + +func resolveContainers(c *client.RancherClient, names []string) ([]client.Instance, error) { + result := []client.Instance{} + + for _, name := range names { + resource, err := Lookup(c, name, "container", "service", "environment") + if err != nil { + return nil, err + } + if resource.Type == "container" { + i, err := c.Instance.ById(resource.Id) + if err != nil { + return nil, err + } + result = append(result, *i) + } else if resource.Type == "environment" { + services := client.ServiceCollection{} + err := c.GetLink(*resource, "services", &services) + if err != nil { + return nil, err + } + serviceIds := []string{} + for _, s := range services.Data { + serviceIds = append(serviceIds, s.Id) + } + instances, err := resolveContainers(c, serviceIds) + if err != nil { + return nil, err + } + result = append(result, instances...) + } else { + instances := client.InstanceCollection{} + err := c.GetLink(*resource, "instances", &instances) + if err != nil { + return nil, err + } + for _, instance := range instances.Data { + result = append(result, instance) + } + } + } + + return result, nil +} diff --git a/cmd/ps.go b/cmd/ps.go new file mode 100644 index 00000000..a5e9b2e8 --- /dev/null +++ b/cmd/ps.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "strings" + + "github.com/pkg/errors" + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +func PsCommand() cli.Command { + return cli.Command{ + Name: "ps", + Usage: "Show services/containers", + Action: servicePs, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "containers,c", + Usage: "Display containers", + }, + cli.BoolFlag{ + Name: "quiet,q", + Usage: "Only display IDs", + }, + cli.StringFlag{ + Name: "format", + Usage: "'json' or Custom format: {{.Id}} {{.Name}", + }, + }, + } +} + +func GetStackMap(c *client.RancherClient) map[string]client.Environment { + result := map[string]client.Environment{} + + stacks, err := c.Environment.List(&client.ListOpts{ + Filters: map[string]interface{}{ + "limit": -1, + }, + }) + + if err != nil { + return result + } + + for _, stack := range stacks.Data { + result[stack.Id] = stack + } + + return result +} + +type PsData struct { + Service client.Service + Stack client.Environment + CombinedState string + ID string +} + +type ContainerPsData struct { + ID string + Container client.Container + CombinedState string + DockerID string +} + +func servicePs(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + if ctx.Bool("containers") { + return hostContainerPs(ctx, c) + } + + if len(ctx.Args()) > 0 { + return serviceContainersPs(ctx, c, ctx.Args()) + } + + stackMap := GetStackMap(c) + + collection, err := c.Service.List(nil) + if err != nil { + return errors.Wrap(err, "service list failed") + } + + writer := NewTableWriter([][]string{ + {"ID", "Service.Id"}, + {"TYPE", "Service.Type"}, + {"NAME", "{{.Stack.Name}}/{{.Service.Name}}"}, + {"IMAGE", "Service.LaunchConfig.ImageUuid"}, + {"STATE", "CombinedState"}, + {"SCALE", "Service.Scale"}, + {"ENDPOINTS", "{{endpoint .Service.PublicEndpoints}}"}, + {"DETAIL", "Service.TransitioningMessage"}, + }, ctx) + + defer writer.Close() + + for _, item := range collection.Data { + if item.LaunchConfig != nil { + item.LaunchConfig.ImageUuid = strings.TrimPrefix(item.LaunchConfig.ImageUuid, "docker:") + } + + combined := item.HealthState + if item.State != "active" || combined == "" { + combined = item.State + } + if item.LaunchConfig == nil { + item.LaunchConfig = &client.LaunchConfig{} + } + writer.Write(PsData{ + ID: item.Id, + Service: item, + Stack: stackMap[item.EnvironmentId], + CombinedState: combined, + }) + } + + return writer.Err() +} + +func serviceContainersPs(ctx *cli.Context, c *client.RancherClient, names []string) error { + containerList := []client.Container{} + + for _, name := range names { + service, err := Lookup(c, name, "service") + if err != nil { + return err + } + + var containers client.ContainerCollection + if err := c.GetLink(*service, "instances", &containers); err != nil { + return err + } + + containerList = append(containerList, containers.Data...) + } + + return containerPs(ctx, containerList) +} + +func hostContainerPs(ctx *cli.Context, c *client.RancherClient) error { + if len(ctx.Args()) == 0 { + containerList, err := c.Container.List(nil) + if err != nil { + return err + } + return containerPs(ctx, containerList.Data) + } + + containers := []client.Container{} + for _, hostname := range ctx.Args() { + host, err := Lookup(c, hostname, "host") + if err != nil { + return err + } + + var containerList client.ContainerCollection + if err := c.GetLink(*host, "instances", &containerList); err != nil { + return err + } + + containers = append(containers, containerList.Data...) + } + + return containerPs(ctx, containers) +} + +func containerPs(ctx *cli.Context, containers []client.Container) error { + writer := NewTableWriter([][]string{ + {"ID", "ID"}, + {"NAME", "Container.Name"}, + {"IMAGE", "Container.ImageUuid"}, + {"STATE", "CombinedState"}, + {"HOST", "Container.HostId"}, + {"IP", "Container.PrimaryIpAddress"}, + {"DOCKER", "DockerID"}, + {"DETAIL", "Container.TransitioningMessage"}, + //TODO: {"PORTS", "{{ports .Container.Ports}}"}, + }, ctx) + defer writer.Close() + + for _, container := range containers { + container.ImageUuid = strings.TrimPrefix(container.ImageUuid, "docker:") + combined := container.HealthState + if container.State != "running" || combined == "" { + combined = container.State + } + containerID := container.ExternalId + if len(containerID) > 12 { + containerID = containerID[:12] + } + writer.Write(ContainerPsData{ + Container: container, + ID: container.Id, + DockerID: containerID, + CombinedState: combined, + }) + } + + return writer.Err() +} diff --git a/cmd/restart.go b/cmd/restart.go new file mode 100644 index 00000000..2a54635f --- /dev/null +++ b/cmd/restart.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +var ( + restartTypes = cli.StringSlice([]string{"service", "container"}) +) + +func RestartCommand() cli.Command { + return cli.Command{ + Name: "restart", + Usage: "Restart " + strings.Join(restartTypes, ", "), + Action: restartResources, + Flags: []cli.Flag{ + cli.StringSliceFlag{ + Name: "type", + Usage: "Restrict restart to specific types", + Value: &restartTypes, + }, + cli.IntFlag{ + Name: "batch-size", + Usage: "Number of containers to restart at a time", + Value: 1, + }, + cli.IntFlag{ + Name: "interval", + Usage: "Interval in millisecond to wait between restarts", + Value: 1000, + }, + }, + } +} + +func restartResources(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + types := ctx.StringSlice("type") + + var lastErr error + for _, id := range ctx.Args() { + resource, err := Lookup(c, id, types...) + if err != nil { + lastErr = err + fmt.Println(lastErr) + continue + } + + if err := c.Action(resource.Type, "restart", resource, &client.ServiceRestart{ + RollingRestartStrategy: client.RollingRestartStrategy{ + BatchSize: int64(ctx.Int("batch-size")), + IntervalMillis: int64(ctx.Int("interval")), + }, + }, resource); err != nil { + lastErr = err + fmt.Println(lastErr) + } else { + fmt.Println(resource.Id) + } + } + + return lastErr +} diff --git a/cmd/rm.go b/cmd/rm.go new file mode 100644 index 00000000..db641a33 --- /dev/null +++ b/cmd/rm.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "fmt" + + "github.com/urfave/cli" +) + +var ( + rmTypes = []string{"service", "container", "host", "environment", "machine"} +) + +func RmCommand() cli.Command { + return cli.Command{ + Name: "rm", + Usage: "Delete resources", + Action: deleteResources, + Flags: []cli.Flag{ + cli.StringSliceFlag{ + Name: "type", + Usage: "Restrict delete to specific types", + Value: &cli.StringSlice{}, + }, + }, + } +} + +func deleteResources(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + w, err := NewWaiter(ctx) + if err != nil { + return err + } + + types := ctx.StringSlice("type") + if len(types) == 0 { + types = rmTypes + } + + var lastErr error + for _, id := range ctx.Args() { + resource, err := Lookup(c, id, types...) + if err != nil { + lastErr = err + fmt.Println(lastErr) + continue + } + + if err := c.Delete(resource); err != nil { + lastErr = err + fmt.Println(lastErr) + } else { + w.Add(resource.Id) + } + } + + if lastErr != nil { + return lastErr + } + + return w.Wait() +} diff --git a/cmd/run.go b/cmd/run.go new file mode 100644 index 00000000..1343ce6e --- /dev/null +++ b/cmd/run.go @@ -0,0 +1,173 @@ +package cmd + +import ( + "strings" + + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +/* + -a, --attach=[] Attach to STDIN, STDOUT or STDERR + --add-host=[] Add a custom host-to-IP mapping (host:ip) + --blkio-weight Block IO (relative weight), between 10 and 1000 + --blkio-weight-device=[] Block IO weight (relative device weight) + --cpu-shares CPU shares (relative weight) + --cap-add=[] Add Linux capabilities + --cap-drop=[] Drop Linux capabilities + --cgroup-parent Optional parent cgroup for the container + --cidfile Write the container ID to the file + --cpu-period Limit CPU CFS (Completely Fair Scheduler) period + --cpu-quota Limit CPU CFS (Completely Fair Scheduler) quota + --cpuset-cpus CPUs in which to allow execution (0-3, 0,1) + --cpuset-mems MEMs in which to allow execution (0-3, 0,1) + -d, --detach Run container in background and print container ID + --detach-keys Override the key sequence for detaching a container + --device=[] Add a host device to the container + --device-read-bps=[] Limit read rate (bytes per second) from a device + --device-read-iops=[] Limit read rate (IO per second) from a device + --device-write-bps=[] Limit write rate (bytes per second) to a device + --device-write-iops=[] Limit write rate (IO per second) to a device + --disable-content-trust=true Skip image verification + --dns=[] Set custom DNS servers + --dns-opt=[] Set DNS options + --dns-search=[] Set custom DNS search domains + -e, --env=[] Set environment variables + --entrypoint Overwrite the default ENTRYPOINT of the image + --env-file=[] Read in a file of environment variables + --expose=[] Expose a port or a range of ports + --group-add=[] Add additional groups to join + -h, --hostname Container host name + --help Print usage + -i, --interactive Keep STDIN open even if not attached + --ip Container IPv4 address (e.g. 172.30.100.104) + --ip6 Container IPv6 address (e.g. 2001:db8::33) + --ipc IPC namespace to use + --isolation Container isolation level + --kernel-memory Kernel memory limit + -l, --label=[] Set meta data on a container + --label-file=[] Read in a line delimited file of labels + --link=[] Add link to another container + --log-driver Logging driver for container + --log-opt=[] Log driver options + -m, --memory Memory limit + --mac-address Container MAC address (e.g. 92:d0:c6:0a:29:33) + --memory-reservation Memory soft limit + --memory-swap Swap limit equal to memory plus swap: '-1' to enable unlimited swap + --memory-swappiness=-1 Tune container memory swappiness (0 to 100) + --name Assign a name to the container + --net=default Connect a container to a network + --net-alias=[] Add network-scoped alias for the container + --oom-kill-disable Disable OOM Killer + --oom-score-adj Tune host's OOM preferences (-1000 to 1000) + -P, --publish-all Publish all exposed ports to random ports + -p, --publish=[] Publish a container's port(s) to the host + --pid PID namespace to use + --privileged Give extended privileges to this container + --read-only Mount the container's root filesystem as read only + --restart=no Restart policy to apply when a container exits + --rm Automatically remove the container when it exits + --security-opt=[] Security Options + --shm-size Size of /dev/shm, default value is 64MB + --sig-proxy=true Proxy received signals to the process + --stop-signal=SIGTERM Signal to stop a container, SIGTERM by default + --tmpfs=[] Mount a tmpfs directory + -u, --user Username or UID (format: [:]) + --ulimit=[] Ulimit options + --uts UTS namespace to use + -v, --volume=[] Bind mount a volume + --volume-driver Optional volume driver for the container + --volumes-from=[] Mount volumes from the specified container(s) + -w, --workdir Working directory inside the container +*/ + +func RunCommand() cli.Command { + return cli.Command{ + Name: "run", + Usage: "Run services", + Action: serviceRun, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "interactive, i", + Usage: "Keep STDIN open even if not attached", + }, + cli.BoolFlag{ + Name: "tty, t", + Usage: "Allocate a pseudo-TTY", + }, + cli.StringFlag{ + Name: "name", + Usage: "Assign a name to the container", + }, + cli.StringSliceFlag{ + Name: "publish, p", + Usage: "Publish a container's `port`(s) to the host", + }, + }, + } +} + +func ParseName(c *client.RancherClient, name string) (*client.Environment, string, error) { + stackName := "" + serviceName := name + + parts := strings.SplitN(name, "/", 2) + if len(parts) == 2 { + stackName = parts[0] + serviceName = parts[1] + } + + stack, err := GetOrCreateDefaultStack(c, stackName) + if err != nil { + return stack, "", err + } + + if serviceName == "" { + serviceName = RandomName() + } + + return stack, serviceName, nil +} + +func serviceRun(ctx *cli.Context) error { + c, err := GetClient(ctx) + if ctx.NArg() < 1 { + return cli.NewExitError("Image name is required as the first argument", 1) + } + + if err != nil { + return err + } + + launchConfig := &client.LaunchConfig{ + StdinOpen: ctx.Bool("interactive"), + Tty: ctx.Bool("tty"), + ImageUuid: "docker:" + ctx.Args()[0], + Ports: ctx.StringSlice("publish"), + } + + args := ctx.Args()[1:] + + if len(args) > 0 { + launchConfig.Command = args + } + + stack, name, err := ParseName(c, ctx.String("name")) + if err != nil { + return err + } + + service := &client.Service{ + Name: name, + EnvironmentId: stack.Id, + LaunchConfig: launchConfig, + StartOnCreate: true, + } + + service, err = c.Service.Create(service) + if err != nil { + return err + } + + return WaitFor(ctx, service.Id) +} diff --git a/cmd/scale.go b/cmd/scale.go new file mode 100644 index 00000000..86c45752 --- /dev/null +++ b/cmd/scale.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +func ScaleCommand() cli.Command { + return cli.Command{ + Name: "scale", + Usage: "Scale a service", + Action: serviceScale, + } +} + +type scaleUp struct { + name string + resource *client.Resource + scale int +} + +func serviceScale(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + servicesToScale := []scaleUp{} + for _, arg := range ctx.Args() { + scale := 1 + parts := strings.SplitN(arg, "=", 2) + if len(parts) > 1 { + i, err := strconv.Atoi(parts[1]) + if err != nil { + return fmt.Errorf("Invalid format for %s, expecting name=scale, example: web=2", arg) + } + scale = i + } + + resource, err := Lookup(c, parts[0], "service") + if err != nil { + return err + } + + servicesToScale = append(servicesToScale, scaleUp{ + name: parts[0], + resource: resource, + scale: scale, + }) + } + + w, err := NewWaiter(ctx) + if err != nil { + return err + } + for _, toScale := range servicesToScale { + w.Add(toScale.name) + + err := c.Update("service", toScale.resource, map[string]interface{}{ + "scale": toScale.scale, + }, toScale.resource) + if err != nil { + return err + } + } + + return w.Wait() +} diff --git a/cmd/ssh.go b/cmd/ssh.go new file mode 100644 index 00000000..64c24010 --- /dev/null +++ b/cmd/ssh.go @@ -0,0 +1,153 @@ +package cmd + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/exec" + "path" + "strings" + + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +func SSHCommand() cli.Command { + return cli.Command{ + Name: "ssh", + Usage: "SSH into host", + Action: hostSSH, + SkipFlagParsing: true, + } +} + +func hostSSH(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + hostname := "" + args := ctx.Args() + + for _, arg := range args { + if len(arg) > 0 && arg[0] != '-' { + parts := strings.SplitN(arg, "@", 2) + hostname = parts[len(parts)-1] + break + } + } + + if hostname == "" { + return fmt.Errorf("Failed to find hostname in %v", args) + } + + host, err := Lookup(c, hostname, "host") + if err != nil { + return err + } + + var physicalHost client.PhysicalHost + err = c.GetLink(*host, "physicalHost", &physicalHost) + if err != nil { + return err + } + + if physicalHost.Type != "machine" { + return fmt.Errorf("Can only SSH to docker-machine created hosts. No custom hosts") + } + + key, err := getSSHKey(hostname, physicalHost) + if err != nil { + return err + } + + ips := client.IpAddressCollection{} + if err := c.GetLink(*host, "ipAddresses", &ips); err != nil { + return err + } + + if len(ips.Data) == 0 { + return fmt.Errorf("Failed to find IP for %s", hostname) + } + + return processExitCode(callSSH(key, ips.Data[0].Address, ctx.Args())) +} + +func callSSH(content []byte, ip string, args []string) error { + for i, val := range args { + if !strings.HasPrefix(val, "-") && len(val) > 0 { + parts := strings.SplitN(val, "@", 2) + parts[len(parts)-1] = ip + args[i] = strings.Join(parts, "@") + } + } + + tmpfile, err := ioutil.TempFile("", "ssh") + if err != nil { + return err + } + defer os.Remove(tmpfile.Name()) + + if err := os.Chmod(tmpfile.Name(), 0600); err != nil { + return err + } + + _, err = tmpfile.Write(content) + if err != nil { + return err + } + + if err := tmpfile.Close(); err != nil { + return err + } + + cmd := exec.Command("ssh", append([]string{"-i", tmpfile.Name()}, args...)...) + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func getSSHKey(hostname string, physicalHost client.PhysicalHost) ([]byte, error) { + link, ok := physicalHost.Links["config"] + if !ok { + return nil, fmt.Errorf("Failed to find SSH key for %s", hostname) + } + + resp, err := http.Get(link) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + tarGz, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("%s", tarGz) + } + + gzipIn, err := gzip.NewReader(bytes.NewBuffer(tarGz)) + if err != nil { + return nil, err + } + tar := tar.NewReader(gzipIn) + + for { + header, err := tar.Next() + if err != nil { + return nil, err + } + + if path.Base(header.Name) == "id_rsa" { + return ioutil.ReadAll(tar) + } + } +} diff --git a/cmd/stack.go b/cmd/stack.go new file mode 100644 index 00000000..5211c5ce --- /dev/null +++ b/cmd/stack.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "strings" + + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +func StackCommand() cli.Command { + return cli.Command{ + Name: "stacks", + ShortName: "stack", + Usage: "Operations on stacks", + Action: stackLs, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "quiet,q", + Usage: "Only display IDs", + }, + cli.StringFlag{ + Name: "format", + Usage: "'json' or Custom format: {{.Id}} {{.Name}", + }, + }, + } +} + +type StackData struct { + ID string + Catalog string + Stack client.Environment + State string + System bool +} + +func stackLs(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + collection, err := c.Environment.List(nil) + if err != nil { + return err + } + + writer := NewTableWriter([][]string{ + {"ID", "ID"}, + {"NAME", "Stack.Name"}, + {"STATE", "State"}, + {"CATALOG", "Catalog"}, + {"SYSTEM", "System"}, + {"DETAIL", "Stack.TransitioningMessage"}, + }, ctx) + + defer writer.Close() + + for _, item := range collection.Data { + system := strings.HasPrefix(item.ExternalId, "system://") + if !system { + system = strings.HasPrefix(item.ExternalId, "system-catalog://") + } + if !system { + system = strings.HasPrefix(item.ExternalId, "kubernetes") + } + combined := item.HealthState + if item.State != "active" || combined == "" { + combined = item.State + } + writer.Write(&StackData{ + ID: item.Id, + Stack: item, + State: combined, + System: system, + Catalog: item.ExternalId, + }) + } + + return writer.Err() +} diff --git a/cmd/start.go b/cmd/start.go new file mode 100644 index 00000000..19935adb --- /dev/null +++ b/cmd/start.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/urfave/cli" +) + +var ( + startTypes = cli.StringSlice([]string{"service", "container", "host"}) +) + +func StartCommand() cli.Command { + return cli.Command{ + Name: "start", + ShortName: "activate", + Usage: "Start or activate " + strings.Join(startTypes, ", "), + Action: startResources, + Flags: []cli.Flag{ + cli.StringSliceFlag{ + Name: "type", + Usage: "Restrict start to specific types", + Value: &startTypes, + }, + }, + } +} + +func startResources(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + types := ctx.StringSlice("type") + + w, err := NewWaiter(ctx) + if err != nil { + return err + } + + var lastErr error + for _, id := range ctx.Args() { + resource, err := Lookup(c, id, types...) + if err != nil { + lastErr = err + fmt.Println(lastErr) + continue + } + + action := "activate" + if _, ok := resource.Actions["start"]; ok { + action = "start" + } + + if err := c.Action(resource.Type, action, resource, nil, resource); err != nil { + lastErr = err + fmt.Println(lastErr) + } else { + w.Add(resource.Id) + } + } + + if lastErr != nil { + return lastErr + } + + return w.Wait() +} diff --git a/cmd/stop.go b/cmd/stop.go new file mode 100644 index 00000000..c6fdfd4a --- /dev/null +++ b/cmd/stop.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/urfave/cli" +) + +var ( + stopTypes = cli.StringSlice([]string{"service", "container", "host", "account"}) +) + +func StopCommand() cli.Command { + return cli.Command{ + Name: "stop", + ShortName: "deactivate", + Usage: "Stop or deactivate " + strings.Join(stopTypes, ", "), + Action: stopResources, + Flags: []cli.Flag{ + cli.StringSliceFlag{ + Name: "type", + Usage: "Restrict stop to specific types", + Value: &stopTypes, + }, + }, + } +} + +func stopResources(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + types := ctx.StringSlice("type") + + w, err := NewWaiter(ctx) + if err != nil { + return err + } + + var lastErr error + for _, id := range ctx.Args() { + resource, err := Lookup(c, id, types...) + if err != nil { + lastErr = err + fmt.Println(lastErr) + continue + } + + action := "" + if _, ok := resource.Actions["stop"]; ok { + action = "stop" + } else if _, ok := resource.Actions["deactivate"]; ok { + action = "deactivate" + } + + if action == "" { + lastErr = fmt.Errorf("stop or deactivate not available on %s", id) + fmt.Println(lastErr) + } else if err := c.Action(resource.Type, action, resource, nil, resource); err != nil { + lastErr = err + fmt.Println(lastErr) + } else { + w.Add(resource.Id) + } + } + + if lastErr != nil { + return lastErr + } + + return w.Wait() +} diff --git a/cmd/up.go b/cmd/up.go new file mode 100644 index 00000000..b39b0237 --- /dev/null +++ b/cmd/up.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "github.com/docker/libcompose/project" + rancherApp "github.com/rancher/rancher-compose/app" + "github.com/urfave/cli" +) + +func UpCommand() cli.Command { + factory := &projectFactory{} + return rancherApp.UpCommand(factory) +} + +type projectFactory struct { +} + +func (p *projectFactory) Create(c *cli.Context) (project.APIProject, error) { + factory := &rancherApp.ProjectFactory{} + + config, err := lookupConfig(c) + if err != nil { + return nil, err + } + + url, err := config.EnvironmentURL() + if err != nil { + return nil, err + } + + c.GlobalSet("url", url) + c.GlobalSet("access-key", config.AccessKey) + c.GlobalSet("secret-key", config.SecretKey) + c.GlobalSet("project-name", c.GlobalString("stack")) + + return factory.Create(c) +} diff --git a/cmd/volume.go b/cmd/volume.go new file mode 100644 index 00000000..b301761a --- /dev/null +++ b/cmd/volume.go @@ -0,0 +1,167 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +func VolumeCommand() cli.Command { + return cli.Command{ + Name: "volumes", + ShortName: "volume", + Usage: "Operations on volumes", + Action: volumeLs, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "quiet,q", + Usage: "Only display IDs", + }, + cli.StringFlag{ + Name: "format", + Usage: "'json' or Custom format: {{.Id}} {{.Name}", + }, + }, + Subcommands: []cli.Command{ + { + Name: "ls", + Usage: "List volumes", + Action: volumeLs, + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "quiet,q", + Usage: "Only display IDs", + }, + cli.StringFlag{ + Name: "format", + Usage: "'json' or Custom format: {{.Id}} {{.Name}", + }, + }, + }, + { + Name: "rm", + Usage: "Delete volume", + Action: volumeRm, + }, + { + Name: "create", + Usage: "Create volume", + Action: volumeCreate, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "driver", + Usage: "Specify volume driver name", + }, + cli.StringSliceFlag{ + Name: "opt", + Usage: "Set driver specific key/value options", + }, + }, + }, + }, + } +} + +type VolumeData struct { + ID string + Volume client.Volume +} + +func volumeLs(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + collection, err := c.Volume.List(&client.ListOpts{ + Filters: map[string]interface{}{ + "limit": -1, + }, + }) + if err != nil { + return err + } + + writer := NewTableWriter([][]string{ + {"ID", "ID"}, + {"NAME", "Volume.Name"}, + {"STATE", "Volume.State"}, + {"DRIVER", "Volume.Driver"}, + {"DETAIL", "Volume.TransitioningMessage"}, + }, ctx) + + defer writer.Close() + + for _, item := range collection.Data { + writer.Write(&VolumeData{ + ID: item.Id, + Volume: item, + }) + } + + return writer.Err() +} + +func volumeRm(ctx *cli.Context) error { + c, err := GetClient(ctx) + if err != nil { + return err + } + + var lastErr error + for _, id := range ctx.Args() { + volume, err := Lookup(c, id, "volume") + if err != nil { + lastErr = err + logrus.Errorf("Failed to delete %s: %v", id, err) + continue + } + + if err := c.Delete(volume); err != nil { + lastErr = err + logrus.Errorf("Failed to delete %s: %v", id, err) + continue + } + + fmt.Println(volume.Id) + } + + return lastErr +} + +func volumeCreate(ctx *cli.Context) error { + if ctx.NArg() < 1 { + return cli.NewExitError("Volume name is required as the first argument", 1) + } + + c, err := GetClient(ctx) + if err != nil { + return err + } + + newVol := &client.Volume{ + Name: ctx.Args()[0], + Driver: ctx.String("driver"), + DriverOpts: map[string]interface{}{}, + } + + for _, arg := range ctx.StringSlice("opt") { + parts := strings.SplitN(arg, "=", 2) + if len(parts) == 1 { + newVol.DriverOpts[parts[0]] = "" + } else { + newVol.DriverOpts[parts[0]] = parts[1] + } + } + + newVol, err = c.Volume.Create(newVol) + if err != nil { + return err + } + + fmt.Println(newVol.Id) + return nil +} diff --git a/cmd/wait.go b/cmd/wait.go new file mode 100644 index 00000000..245bc28d --- /dev/null +++ b/cmd/wait.go @@ -0,0 +1,198 @@ +package cmd + +import ( + "fmt" + "strings" + "time" + + "github.com/Sirupsen/logrus" + "github.com/rancher/cli/monitor" + "github.com/rancher/go-rancher/client" + "github.com/urfave/cli" +) + +var ( + waitTypes = []string{"service", "container", "host", "environment", "machine"} +) + +func WaitCommand() cli.Command { + return cli.Command{ + Name: "wait", + Usage: "Wait for resources", + Action: waitForResources, + Flags: []cli.Flag{}, + } +} + +func WaitFor(ctx *cli.Context, resource string) error { + w, err := NewWaiter(ctx) + if err != nil { + return err + } + w.Add(resource) + return w.Wait() +} + +func waitForResources(ctx *cli.Context) error { + ctx.GlobalSet("wait", "true") + + w, err := NewWaiter(ctx) + if err != nil { + return err + } + + for _, r := range ctx.Args() { + w.Add(r) + } + + return w.Wait() +} + +func NewWaiter(ctx *cli.Context) (*Waiter, error) { + client, err := GetClient(ctx) + if err != nil { + return nil, err + } + + return &Waiter{ + enabled: ctx.GlobalBool("wait"), + timeout: ctx.GlobalInt("wait-timeout"), + state: ctx.GlobalString("wait-state"), + client: client, + }, nil +} + +type Waiter struct { + enabled bool + timeout int + state string + resources []string + client *client.RancherClient + monitor *monitor.Monitor +} + +type ResourceID string + +func NewResourceID(resourceType, id string) ResourceID { + return ResourceID(fmt.Sprintf("%s:%s", resourceType, id)) +} + +func (r ResourceID) ID() string { + str := string(r) + return str[strings.Index(str, ":")+1:] +} + +func (r ResourceID) Type() string { + str := string(r) + return str[:strings.Index(str, ":")] +} + +func (w *Waiter) Add(resource string) { + fmt.Println(resource) + w.resources = append(w.resources, resource) +} + +func (w *Waiter) done(resourceType, id string) (bool, error) { + data := map[string]interface{}{} + ok, err := w.monitor.Get(resourceType, id, &data) + if err != nil { + return ok, err + } + + if ok { + return w.checkDone(resourceType, id, data) + } + + if err := w.client.ById(resourceType, id, &data); err != nil { + return false, err + } + + return w.checkDone(resourceType, id, data) +} + +func (w *Waiter) checkDone(resourceType, id string, data map[string]interface{}) (bool, error) { + transitioning := fmt.Sprint(data["transitioning"]) + logrus.Debugf("%s:%s transitioning=%s state=%v, healthState=%v waitState=%s", resourceType, id, transitioning, + data["state"], data["healthState"], w.state) + + switch transitioning { + case "yes": + return false, nil + case "error": + return false, fmt.Errorf("%s:%s failed: %s", resourceType, id, data["transitioningMessage"]) + } + + if w.state == "" { + return true, nil + } + + if w.state == "healthy" { + return data["healthState"] == w.state, nil + } + + return data["state"] == w.state, nil +} + +func (w *Waiter) Wait() error { + if !w.enabled { + return nil + } + + watching := map[ResourceID]bool{} + w.monitor = monitor.New(w.client) + sub := w.monitor.Subscribe() + go func() { logrus.Fatal(w.monitor.Start()) }() + + for _, resource := range w.resources { + r, err := Lookup(w.client, resource, waitTypes...) + if err != nil { + return err + } + + ok, err := w.done(r.Type, r.Id) + if err != nil { + return err + } + if !ok { + watching[NewResourceID(r.Type, r.Id)] = true + } + } + + timeout := time.After(time.Duration(w.timeout) * time.Second) + every := time.Tick(10 * time.Second) + for len(watching) > 0 { + var event *monitor.Event + select { + case event = <-sub.C: + case <-timeout: + return fmt.Errorf("Timeout") + case <-every: + for resource := range watching { + ok, err := w.done(resource.Type(), resource.ID()) + if err != nil { + return err + } + if ok { + delete(watching, resource) + } + } + continue + } + + resource := NewResourceID(event.ResourceType, event.ResourceID) + if !watching[resource] { + continue + } + + done, err := w.done(event.ResourceType, event.ResourceID) + if err != nil { + return err + } + + if done { + delete(watching, resource) + } + } + + return nil +} diff --git a/cmd/writer.go b/cmd/writer.go new file mode 100644 index 00000000..a9c9b420 --- /dev/null +++ b/cmd/writer.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "encoding/json" + "os" + "text/tabwriter" + + "github.com/urfave/cli" +) + +type TableWriter struct { + quite bool + HeaderFormat string + ValueFormat string + err error + headerPrinted bool + Writer *tabwriter.Writer +} + +func NewTableWriter(values [][]string, ctx *cli.Context) *TableWriter { + t := &TableWriter{ + Writer: tabwriter.NewWriter(os.Stdout, 10, 1, 3, ' ', 0), + } + t.HeaderFormat, t.ValueFormat = SimpleFormat(values) + + if ctx.Bool("quiet") { + t.HeaderFormat = "" + t.ValueFormat = "{{.ID}}\n" + } + + customFormat := ctx.String("format") + if customFormat == "json" { + t.HeaderFormat = "" + t.ValueFormat = "json" + } else if customFormat != "" { + t.ValueFormat = customFormat + "\n" + t.HeaderFormat = "" + } + + return t +} + +func (t *TableWriter) Err() error { + return t.err +} + +func (t *TableWriter) writeHeader() { + if t.HeaderFormat != "" && !t.headerPrinted { + t.headerPrinted = true + t.err = printTemplate(t.Writer, t.HeaderFormat, struct{}{}) + if t.err != nil { + return + } + } +} + +func (t *TableWriter) Write(obj interface{}) { + if t.err != nil { + return + } + + t.writeHeader() + if t.err != nil { + return + } + + if t.ValueFormat == "json" { + content, err := json.Marshal(obj) + t.err = err + if t.err != nil { + return + } + _, t.err = t.Writer.Write(append(content, byte('\n'))) + } else { + t.err = printTemplate(t.Writer, t.ValueFormat, obj) + } +} + +func (t *TableWriter) Close() error { + if t.err != nil { + return t.err + } + t.writeHeader() + if t.err != nil { + return t.err + } + return t.Writer.Flush() +} diff --git a/main.go b/main.go index cca1dc75..6ac962c1 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,154 @@ package main import ( + "os" + "github.com/Sirupsen/logrus" + "github.com/rancher/cli/cmd" + "github.com/urfave/cli" ) +var VERSION = "dev" + +var AppHelpTemplate = `Usage: {{.Name}} {{if .Flags}}[OPTIONS] {{end}}COMMAND [arg...] + +{{.Usage}} + +Version: {{.Version}} +{{if .Flags}} +Options: + {{range .Flags}}{{.}} + {{end}}{{end}} +Commands: + {{range .Commands}}{{.Name}}{{with .ShortName}}, {{.}}{{end}}{{ "\t" }}{{.Usage}} + {{end}} +Run '{{.Name}} COMMAND --help' for more information on a command. +` + +var CommandHelpTemplate = `Usage: rancher {{.Name}}{{if .Flags}} [OPTIONS]{{end}} [arg...] + +{{.Usage}}{{if .Description}} + +Description: + {{.Description}}{{end}}{{if .Flags}} + +Options: + {{range .Flags}} + {{.}}{{end}}{{ end }} +` + func main() { - logrus.Info("I'm a turkey") + if err := mainErr(); err != nil { + logrus.Fatal(err) + } +} + +func mainErr() error { + cli.AppHelpTemplate = AppHelpTemplate + cli.CommandHelpTemplate = CommandHelpTemplate + + app := cli.NewApp() + app.Name = "rancher" + app.Usage = "Rancher CLI, managing containers one UTF-8 character at a time" + app.Before = func(ctx *cli.Context) error { + if ctx.GlobalBool("debug") { + logrus.SetLevel(logrus.DebugLevel) + } + return nil + } + app.Version = VERSION + app.Author = "Rancher Labs, Inc." + app.Email = "" + app.Flags = []cli.Flag{ + cli.BoolFlag{ + Name: "debug", + Usage: "Debug logging", + }, + cli.StringFlag{ + Name: "config,c", + Usage: "Client configuration file (default ${HOME}/.rancher/cli.json)", + EnvVar: "RANCHER_CLIENT_CONFIG", + }, + cli.StringFlag{ + Name: "environment,env", + Usage: "Environment name or ID", + EnvVar: "RANCHER_ENVIRONMENT", + }, + cli.StringFlag{ + Name: "url", + Usage: "Specify the Rancher API endpoint URL", + EnvVar: "RANCHER_URL", + }, + cli.StringFlag{ + Name: "access-key", + Usage: "Specify Rancher API access key", + EnvVar: "RANCHER_ACCESS_KEY", + }, + cli.StringFlag{ + Name: "secret-key", + Usage: "Specify Rancher API secret key", + EnvVar: "RANCHER_SECRET_KEY", + }, + cli.StringFlag{ + Name: "host", + Usage: "Host used for docker command", + EnvVar: "RANCHER_DOCKER_HOST", + }, + cli.StringFlag{ + Name: "rancher-file,r", + Usage: "Specify an alternate Rancher compose file (default: rancher-compose.yml)", + }, + cli.StringFlag{ + Name: "env-file,e", + Usage: "Specify a file from which to read environment variables", + }, + cli.StringSliceFlag{ + Name: "file,f", + Usage: "Specify one or more alternate compose files (default: docker-compose.yml)", + Value: &cli.StringSlice{}, + EnvVar: "COMPOSE_FILE", + }, + cli.StringFlag{ + Name: "stack,s", + Usage: "Specify an alternate project name (default: directory name)", + }, + cli.BoolFlag{ + Name: "wait,w", + Usage: "Wait for resource to reach resting state", + }, + cli.IntFlag{ + Name: "wait-timeout", + Usage: "Timeout in seconds to wait", + Value: 600, + }, + cli.StringFlag{ + Name: "wait-state", + Usage: "State to wait for (active, healthy, etc)", + }, + } + app.Commands = []cli.Command{ + cmd.CatalogCommand(), + cmd.ConfigCommand(), + cmd.DockerCommand(), + cmd.EnvCommand(), + cmd.EventsCommand(), + cmd.ExecCommand(), + cmd.ExportCommand(), + cmd.HostCommand(), + cmd.LogsCommand(), + cmd.PsCommand(), + cmd.RestartCommand(), + cmd.RmCommand(), + cmd.RunCommand(), + cmd.ScaleCommand(), + cmd.SSHCommand(), + cmd.StackCommand(), + cmd.StartCommand(), + cmd.StopCommand(), + cmd.UpCommand(), + //cmd.VolumeCommand(), + cmd.WaitCommand(), + } + + return app.Run(os.Args) } diff --git a/monitor/monitor.go b/monitor/monitor.go new file mode 100644 index 00000000..f123ccfc --- /dev/null +++ b/monitor/monitor.go @@ -0,0 +1,157 @@ +package monitor + +import ( + "encoding/json" + "fmt" + "net/url" + "sync" + "time" + + "github.com/Sirupsen/logrus" + "github.com/gorilla/websocket" + "github.com/patrickmn/go-cache" + "github.com/rancher/go-rancher/client" +) + +type Event struct { + Name string `json:"name"` + ResourceType string `json:"resourceType"` + ResourceID string `json:"resourceId"` + Data map[string]interface{} `json:"data"` +} + +type Monitor struct { + sync.Mutex + c *client.RancherClient + cache *cache.Cache + subCounter int + subscriptions map[int]*Subscription +} + +func (m *Monitor) Subscribe() *Subscription { + m.Lock() + defer m.Unlock() + + m.subCounter++ + sub := &Subscription{ + id: m.subCounter, + C: make(chan *Event, 1024), + } + m.subscriptions[sub.id] = sub + + return sub +} + +func (m *Monitor) Unsubscribe(sub *Subscription) { + m.Lock() + defer m.Unlock() + + close(sub.C) + delete(m.subscriptions, sub.id) +} + +type Subscription struct { + id int + C chan *Event +} + +func New(c *client.RancherClient) *Monitor { + return &Monitor{ + c: c, + cache: cache.New(5*time.Minute, 30*time.Second), + subscriptions: map[int]*Subscription{}, + } +} + +func (m *Monitor) Start() error { + schema, ok := m.c.Schemas.CheckSchema("subscribe") + if !ok { + return fmt.Errorf("Not authorized to subscribe") + } + + urlString := schema.Links["collection"] + u, err := url.Parse(urlString) + if err != nil { + return err + } + + switch u.Scheme { + case "http": + u.Scheme = "ws" + case "https": + u.Scheme = "wss" + } + + q := u.Query() + q.Add("eventNames", "resource.change") + q.Add("eventNames", "service.kubernetes.change") + + u.RawQuery = q.Encode() + + conn, resp, err := m.c.Websocket(u.String(), nil) + if err != nil { + return err + } + + if resp.StatusCode != 101 { + return fmt.Errorf("Bad status code: %d %s", resp.StatusCode, resp.Status) + } + + logrus.Debugf("Connected to: %s", u.String()) + + return m.watch(conn) +} + +func (m *Monitor) Get(resourceType, resourceID string, obj interface{}) (bool, error) { + val, ok := m.cache.Get(key(resourceType, resourceID)) + if !ok { + return ok, nil + } + + if val == nil { + return true, nil + } + + content, err := json.Marshal(val) + if err != nil { + return ok, err + } + + return true, json.Unmarshal(content, obj) +} + +func key(a, b string) string { + return fmt.Sprintf("%s:%s", a, b) +} + +func (m *Monitor) put(resourceType, resourceID string, event *Event) { + if resourceType == "" && resourceID == "" { + return + } + + m.cache.Replace(key(resourceType, resourceID), event.Data["resource"], cache.DefaultExpiration) + + m.Lock() + defer m.Unlock() + + for _, sub := range m.subscriptions { + sub.C <- event + } +} + +func (m *Monitor) watch(conn *websocket.Conn) error { + for { + v := Event{} + _, r, err := conn.NextReader() + if err != nil { + return err + } + if err := json.NewDecoder(r).Decode(&v); err != nil { + logrus.Errorf("Failed to parse json in message") + continue + } + + logrus.Debugf("Event: %s %s %s", v.Name, v.ResourceType, v.ResourceID) + m.put(v.ResourceType, v.ResourceID, &v) + } +} diff --git a/package/Dockerfile b/package/Dockerfile index 3472e56c..924eeed7 100644 --- a/package/Dockerfile +++ b/package/Dockerfile @@ -1,3 +1,3 @@ -FROM ubuntu:15.10 -COPY cli /usr/bin/ -CMD ["cli"] +FROM alpine +COPY rancher /usr/bin/ +ENTRYPOINT ["rancher"] diff --git a/scripts/build b/scripts/build index 45db833f..7f07ed6e 100755 --- a/scripts/build +++ b/scripts/build @@ -1,9 +1,33 @@ -#!/bin/bash -set -e +#!/bin/bash -e source $(dirname $0)/version cd $(dirname $0)/.. -mkdir -p bin -go build -ldflags "-X main.VERSION=$VERSION -linkmode external -extldflags -static" -o bin/cli +OS_PLATFORM_ARG=(linux windows darwin) +OS_ARCH_ARG=(amd64 arm) + +go build -ldflags="-w -s -X main.VERSION=$VERSION" -o bin/rancher + +if [ -n "$CROSS" ]; then + rm -rf build/bin + mkdir -p build/bin + for OS in ${OS_PLATFORM_ARG[@]}; do + for ARCH in ${OS_ARCH_ARG[@]}; do + OUTPUT_BIN="build/bin/rancher_$OS-$ARCH" + if test "$ARCH" = "arm"; then + if test "$OS" = "windows" || test "$OS" = "darwin"; then + # windows/arm and darwin/arm does not compile without cgo :-| + continue + fi + fi + if test "$OS" = "windows"; then + OUTPUT_BIN="${OUTPUT_BIN}.exe" + fi + echo "Building binary for $OS/$ARCH..." + GOARCH=$ARCH GOOS=$OS CGO_ENABLED=0 go build \ + -ldflags="-w -X main.VERSION=$VERSION" \ + -o ${OUTPUT_BIN} ./ + done + done +fi diff --git a/scripts/copy-latest.sh b/scripts/copy-latest.sh new file mode 100755 index 00000000..2d114de4 --- /dev/null +++ b/scripts/copy-latest.sh @@ -0,0 +1,2 @@ +#!/bin/bash +gsutil -m rsync -r dist/artifacts/latest/ gs://releases.rancher.com/cli/latest diff --git a/scripts/copy-release.sh b/scripts/copy-release.sh new file mode 100755 index 00000000..6e8e2720 --- /dev/null +++ b/scripts/copy-release.sh @@ -0,0 +1,2 @@ +#!/bin/bash +gsutil -m cp -r dist/artifacts/v* gs://releases.rancher.com/cli diff --git a/scripts/entry b/scripts/entry index 486dc0fa..62e4e140 100755 --- a/scripts/entry +++ b/scripts/entry @@ -3,7 +3,7 @@ set -e trap "chown -R $DAPPER_UID:$DAPPER_GID ." exit -mkdir -p bin +mkdir -p bin build/bin dist if [ -e ./scripts/$1 ]; then ./scripts/"$@" else diff --git a/scripts/package b/scripts/package index 0d63ac83..ac2529b0 100755 --- a/scripts/package +++ b/scripts/package @@ -3,12 +3,56 @@ set -e source $(dirname $0)/version -cd $(dirname $0)/../package +cd $(dirname $0)/.. -TAG=${TAG:-${VERSION}} -REPO=${REPO:-rancher} +DIST=$(pwd)/dist/artifacts -cp ../bin/cli . -docker build -t ${REPO}/cli:${TAG} . +mkdir -p $DIST/${VERSION} $DIST/latest -echo Built ${REPO}/cli:${TAG} +for i in build/bin/*; do + if [ ! -e $i ]; then + continue + fi + + BASE=build/archive + DIR=${BASE}/rancher-${VERSION} + + rm -rf $BASE + mkdir -p $BASE $DIR + + EXT= + if [[ $i =~ .*windows.* ]]; then + EXT=.exe + fi + + cp $i ${DIR}/rancher${EXT} + + arch=$(echo $i | cut -f2 -d_) + mkdir -p $DIST/${VERSION}/binaries/$arch + mkdir -p $DIST/latest/binaries/$arch + cp $i $DIST/${VERSION}/binaries/$arch/rancher${EXT} + if [ -z "${EXT}" ]; then + gzip -c $i > $DIST/${VERSION}/binaries/$arch/rancher.gz + xz -c $i > $DIST/${VERSION}/binaries/$arch/rancher.xz + fi + + rm -rf $DIST/latest/binaries/$arch + mkdir -p $DIST/latest/binaries + cp -rf $DIST/${VERSION}/binaries/$arch $DIST/latest/binaries + + ( + cd $BASE + NAME=$(basename $i | sed 's/_/-/g') + if [ -z "$EXT" ]; then + tar cvzf $DIST/${VERSION}/${NAME}-${VERSION}.tar.gz . + cp $DIST/${VERSION}/${NAME}-${VERSION}.tar.gz $DIST/latest/${NAME}.tar.gz + + tar cvJf $DIST/${VERSION}/${NAME}-${VERSION}.tar.xz . + cp $DIST/${VERSION}/${NAME}-${VERSION}.tar.xz $DIST/latest/${NAME}.tar.xz + else + NAME=$(echo $NAME | sed 's/'${EXT}'//g') + zip -r $DIST/${VERSION}/${NAME}-${VERSION}.zip * + cp $DIST/${VERSION}/${NAME}-${VERSION}.zip $DIST/latest/${NAME}.zip + fi + ) +done