diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 00000000..ae2370a1 --- /dev/null +++ b/ci/README.md @@ -0,0 +1,26 @@ +# ci + +## integration tests + +### `tcli` + +Package `tcli` provides a framework for writing end-to-end CLI tests. +Each test group can have its own container for executing commands in a consistent +and isolated filesystem. + +### prerequisites + +Assign the following environment variables to run the integration tests +against an existing Enterprise deployment instance. + +```bash +export CODER_URL=... +export CODER_EMAIL=... +export CODER_PASSWORD=... +``` + +Then, simply run the test command from the project root + +```sh +go test -v ./ci/integration +``` diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index dc921f29..77a4ec91 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -2,14 +2,11 @@ package integration import ( "context" - "encoding/json" "math/rand" "testing" "time" "cdr.dev/coder-cli/ci/tcli" - "cdr.dev/coder-cli/internal/entclient" - "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest/assert" ) @@ -34,17 +31,15 @@ func TestCoderCLI(t *testing.T) { tcli.StderrEmpty(), ) - c.Run(ctx, "coder version").Assert(t, + c.Run(ctx, "coder --version").Assert(t, tcli.StderrEmpty(), tcli.Success(), tcli.StdoutMatches("linux"), ) - c.Run(ctx, "coder help").Assert(t, + c.Run(ctx, "coder --help").Assert(t, tcli.Success(), - tcli.StderrMatches("Commands:"), - tcli.StderrMatches("Usage: coder"), - tcli.StdoutEmpty(), + tcli.StdoutMatches("Available Commands"), ) headlessLogin(ctx, t, c) @@ -53,8 +48,12 @@ func TestCoderCLI(t *testing.T) { tcli.Success(), ) + c.Run(ctx, "coder envs ls").Assert(t, + tcli.Success(), + ) + c.Run(ctx, "coder urls").Assert(t, - tcli.Error(), + tcli.Success(), ) c.Run(ctx, "coder sync").Assert(t, @@ -65,36 +64,15 @@ func TestCoderCLI(t *testing.T) { tcli.Error(), ) - var user entclient.User - c.Run(ctx, `coder users ls -o json | jq -c '.[] | select( .username == "charlie")'`).Assert(t, - tcli.Success(), - stdoutUnmarshalsJSON(&user), - ) - assert.Equal(t, "user email is as expected", "charlie@coder.com", user.Email) - assert.Equal(t, "username is as expected", "Charlie", user.Name) - - c.Run(ctx, "coder users ls -o human | grep charlie").Assert(t, - tcli.Success(), - tcli.StdoutMatches("charlie"), - ) - c.Run(ctx, "coder logout").Assert(t, tcli.Success(), ) - c.Run(ctx, "coder envs").Assert(t, + c.Run(ctx, "coder envs ls").Assert(t, tcli.Error(), ) } -func stdoutUnmarshalsJSON(target interface{}) tcli.Assertion { - return func(t *testing.T, r *tcli.CommandResult) { - slog.Helper() - err := json.Unmarshal(r.Stdout, target) - assert.Success(t, "json unmarshals", err) - } -} - var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) func randString(length int) string { diff --git a/ci/integration/secrets_test.go b/ci/integration/secrets_test.go index b77c345b..dc063b9b 100644 --- a/ci/integration/secrets_test.go +++ b/ci/integration/secrets_test.go @@ -36,7 +36,6 @@ func TestSecrets(t *testing.T) { c.Run(ctx, "coder secrets create").Assert(t, tcli.Error(), - tcli.StdoutEmpty(), ) // this tests the "Value:" prompt fallback @@ -85,9 +84,6 @@ func TestSecrets(t *testing.T) { c.Run(ctx, fmt.Sprintf("echo %s > ~/secret.json", value)).Assert(t, tcli.Success(), ) - c.Run(ctx, fmt.Sprintf("coder secrets create %s --from-literal %s --from-file ~/secret.json", name, value)).Assert(t, - tcli.Error(), - ) c.Run(ctx, fmt.Sprintf("coder secrets create %s --from-file ~/secret.json", name)).Assert(t, tcli.Success(), ) diff --git a/ci/integration/setup_test.go b/ci/integration/setup_test.go index 9ae69c29..625faa5c 100644 --- a/ci/integration/setup_test.go +++ b/ci/integration/setup_test.go @@ -13,6 +13,7 @@ import ( "golang.org/x/xerrors" ) +// binpath is populated during package initialization with a path to the coder binary var binpath string // initialize integration tests by building the coder-cli binary @@ -39,7 +40,7 @@ func build(path string) error { out, err := cmd.CombinedOutput() if err != nil { - return xerrors.Errorf("failed to build coder-cli (%v): %w", string(out), err) + return xerrors.Errorf("build coder-cli (%v): %w", string(out), err) } return nil } diff --git a/ci/integration/users_test.go b/ci/integration/users_test.go new file mode 100644 index 00000000..659ccc7a --- /dev/null +++ b/ci/integration/users_test.go @@ -0,0 +1,56 @@ +package integration + +import ( + "context" + "testing" + "time" + + "cdr.dev/coder-cli/ci/tcli" + "cdr.dev/coder-cli/internal/entclient" + "cdr.dev/slog/sloggers/slogtest/assert" +) + +func TestUsers(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) + defer cancel() + + c, err := tcli.NewContainerRunner(ctx, &tcli.ContainerConfig{ + Image: "codercom/enterprise-dev", + Name: "users-cli-tests", + BindMounts: map[string]string{ + binpath: "/bin/coder", + }, + }) + assert.Success(t, "new run container", err) + defer c.Close() + + c.Run(ctx, "which coder").Assert(t, + tcli.Success(), + tcli.StdoutMatches("/usr/sbin/coder"), + tcli.StderrEmpty(), + ) + + headlessLogin(ctx, t, c) + + var user entclient.User + c.Run(ctx, `coder users ls --output json | jq -c '.[] | select( .username == "charlie")'`).Assert(t, + tcli.Success(), + tcli.StdoutJSONUnmarshal(&user), + ) + assert.Equal(t, "user email is as expected", "charlie@coder.com", user.Email) + assert.Equal(t, "username is as expected", "Charlie", user.Name) + + c.Run(ctx, "coder users ls --output human | grep charlie").Assert(t, + tcli.Success(), + tcli.StdoutMatches("charlie"), + ) + + c.Run(ctx, "coder logout").Assert(t, + tcli.Success(), + ) + + c.Run(ctx, "coder users ls").Assert(t, + tcli.Error(), + ) +} diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index fe225c6e..51ec6f55 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -3,6 +3,7 @@ package tcli import ( "bytes" "context" + "encoding/json" "fmt" "io" "os/exec" @@ -76,7 +77,7 @@ func NewContainerRunner(ctx context.Context, config *ContainerConfig) (*Containe out, err := cmd.CombinedOutput() if err != nil { return nil, xerrors.Errorf( - "failed to start testing container %q, (%s): %w", + "start testing container %q, (%s): %w", config.Name, string(out), err) } @@ -97,7 +98,7 @@ func (r *ContainerRunner) Close() error { out, err := cmd.CombinedOutput() if err != nil { return xerrors.Errorf( - "failed to stop testing container %q, (%s): %w", + "stop testing container %q, (%s): %w", r.name, string(out), err) } return nil @@ -290,7 +291,7 @@ func matches(t *testing.T, name, pattern string, target []byte) { ok, err := regexp.Match(pattern, target) if err != nil { - slogtest.Fatal(t, "failed to attempt regexp match", append(fields, slog.Error(err))...) + slogtest.Fatal(t, "attempt regexp match", append(fields, slog.Error(err))...) } if !ok { slogtest.Fatal(t, "expected to find pattern, no match found", fields...) @@ -329,3 +330,21 @@ func DurationGreaterThan(dur time.Duration) Assertion { } } } + +// StdoutJSONUnmarshal attempts to unmarshal stdout into the given target +func StdoutJSONUnmarshal(target interface{}) Assertion { + return func(t *testing.T, r *CommandResult) { + slog.Helper() + err := json.Unmarshal(r.Stdout, target) + assert.Success(t, "stdout json unmarshals", err) + } +} + +// StderrJSONUnmarshal attempts to unmarshal stderr into the given target +func StderrJSONUnmarshal(target interface{}) Assertion { + return func(t *testing.T, r *CommandResult) { + slog.Helper() + err := json.Unmarshal(r.Stdout, target) + assert.Success(t, "stderr json unmarshals", err) + } +} diff --git a/cmd/coder/auth.go b/cmd/coder/auth.go index 574a0c0b..d15cf914 100644 --- a/cmd/coder/auth.go +++ b/cmd/coder/auth.go @@ -5,20 +5,38 @@ import ( "cdr.dev/coder-cli/internal/config" "cdr.dev/coder-cli/internal/entclient" + "golang.org/x/xerrors" + + "go.coder.com/flog" ) +// requireAuth exits the process with a nonzero exit code if the user is not authenticated to make requests func requireAuth() *entclient.Client { + client, err := newClient() + if err != nil { + flog.Fatal("%v", err) + } + return client +} + +func newClient() (*entclient.Client, error) { sessionToken, err := config.Session.Read() - requireSuccess(err, "read session: %v (did you run coder login?)", err) + if err != nil { + return nil, xerrors.Errorf("read session: %v (did you run coder login?)", err) + } rawURL, err := config.URL.Read() - requireSuccess(err, "read url: %v (did you run coder login?)", err) + if err != nil { + return nil, xerrors.Errorf("read url: %v (did you run coder login?)", err) + } u, err := url.Parse(rawURL) - requireSuccess(err, "url misformatted: %v (try runing coder login)", err) + if err != nil { + return nil, xerrors.Errorf("url misformatted: %v (try runing coder login)", err) + } return &entclient.Client{ BaseURL: u, Token: sessionToken, - } + }, nil } diff --git a/cmd/coder/ceapi.go b/cmd/coder/ceapi.go index cd350f84..aff2bf5e 100644 --- a/cmd/coder/ceapi.go +++ b/cmd/coder/ceapi.go @@ -1,6 +1,8 @@ package main import ( + "golang.org/x/xerrors" + "go.coder.com/flog" "cdr.dev/coder-cli/internal/entclient" @@ -25,12 +27,16 @@ outer: } // getEnvs returns all environments for the user. -func getEnvs(client *entclient.Client) []entclient.Environment { +func getEnvs(client *entclient.Client) ([]entclient.Environment, error) { me, err := client.Me() - requireSuccess(err, "get self: %+v", err) + if err != nil { + return nil, xerrors.Errorf("get self: %+v", err) + } orgs, err := client.Orgs() - requireSuccess(err, "get orgs: %+v", err) + if err != nil { + return nil, xerrors.Errorf("get orgs: %+v", err) + } orgs = userOrgs(me, orgs) @@ -38,30 +44,33 @@ func getEnvs(client *entclient.Client) []entclient.Environment { for _, org := range orgs { envs, err := client.Envs(me, org) - requireSuccess(err, "get envs for %v: %+v", org.Name, err) + if err != nil { + return nil, xerrors.Errorf("get envs for %v: %+v", org.Name, err) + } for _, env := range envs { allEnvs = append(allEnvs, env) } } - - return allEnvs + return allEnvs, nil } // findEnv returns a single environment by name (if it exists.) -func findEnv(client *entclient.Client, name string) entclient.Environment { - envs := getEnvs(client) +func findEnv(client *entclient.Client, name string) (*entclient.Environment, error) { + envs, err := getEnvs(client) + if err != nil { + return nil, xerrors.Errorf("get environments: %w", err) + } var found []string for _, env := range envs { found = append(found, env.Name) if env.Name == name { - return env + return &env, nil } } - - flog.Info("found %q", found) - flog.Fatal("environment %q not found", name) - panic("unreachable") + flog.Error("found %q", found) + flog.Error("%q not found", name) + return nil, xerrors.New("environment not found") } diff --git a/cmd/coder/configssh.go b/cmd/coder/configssh.go index ff751322..135e7c58 100644 --- a/cmd/coder/configssh.go +++ b/cmd/coder/configssh.go @@ -11,41 +11,33 @@ import ( "strings" "time" - "github.com/spf13/pflag" - "go.coder.com/cli" - "go.coder.com/flog" - "cdr.dev/coder-cli/internal/config" "cdr.dev/coder-cli/internal/entclient" + "github.com/spf13/cobra" + "golang.org/x/xerrors" ) -var ( - privateKeyFilepath = filepath.Join(os.Getenv("HOME"), ".ssh", "coder_enterprise") -) - -type configSSHCmd struct { - filepath string - remove bool - - startToken, startMessage, endToken string -} +func makeConfigSSHCmd() *cobra.Command { + var ( + configpath string + remove = false + ) -func (cmd *configSSHCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "config-ssh", - Usage: "", - Desc: "add your Coder Enterprise environments to ~/.ssh/config", + cmd := &cobra.Command{ + Use: "config-ssh", + Short: "Configure SSH to access Coder environments", + Long: "Inject the proper OpenSSH configuration into your local SSH config file.", + RunE: configSSH(&configpath, &remove), } -} + cmd.Flags().StringVar(&configpath, "filepath", filepath.Join(os.Getenv("HOME"), ".ssh", "config"), "overide the default path of your ssh config file") + cmd.Flags().BoolVar(&remove, "remove", false, "remove the auto-generated Coder Enterprise ssh config") -func (cmd *configSSHCmd) RegisterFlags(fl *pflag.FlagSet) { - fl.BoolVar(&cmd.remove, "remove", false, "remove the auto-generated Coder Enterprise ssh config") - home := os.Getenv("HOME") - defaultPath := filepath.Join(home, ".ssh", "config") - fl.StringVar(&cmd.filepath, "config-path", defaultPath, "overide the default path of your ssh config file") + return cmd +} - cmd.startToken = "# ------------START-CODER-ENTERPRISE-----------" - cmd.startMessage = `# The following has been auto-generated by "coder config-ssh" +func configSSH(filepath *string, remove *bool) func(cmd *cobra.Command, _ []string) error { + startToken := "# ------------START-CODER-ENTERPRISE-----------" + startMessage := `# The following has been auto-generated by "coder config-ssh" # to make accessing your Coder Enterprise environments easier. # # To remove this blob, run: @@ -53,120 +45,124 @@ func (cmd *configSSHCmd) RegisterFlags(fl *pflag.FlagSet) { # coder config-ssh --remove # # You should not hand-edit this section, unless you are deleting it.` - cmd.endToken = "# ------------END-CODER-ENTERPRISE------------" -} - -func (cmd *configSSHCmd) Run(fl *pflag.FlagSet) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + endToken := "# ------------END-CODER-ENTERPRISE------------" + + return func(cmd *cobra.Command, _ []string) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + currentConfig, err := readStr(*filepath) + if os.IsNotExist(err) { + // SSH configs are not always already there. + currentConfig = "" + } else if err != nil { + return xerrors.Errorf("read ssh config file %q: %w", filepath, err) + } - currentConfig, err := readStr(cmd.filepath) - if os.IsNotExist(err) { - // SSH configs are not always already there. - currentConfig = "" - } else if err != nil { - flog.Fatal("failed to read ssh config file %q: %v", cmd.filepath, err) - } + startIndex := strings.Index(currentConfig, startToken) + endIndex := strings.Index(currentConfig, endToken) - startIndex := strings.Index(currentConfig, cmd.startToken) - endIndex := strings.Index(currentConfig, cmd.endToken) + if *remove { + if startIndex == -1 || endIndex == -1 { + return xerrors.Errorf("the Coder Enterprise ssh configuration section could not be safely deleted or does not exist") + } + currentConfig = currentConfig[:startIndex-1] + currentConfig[endIndex+len(endToken)+1:] - if cmd.remove { - if startIndex == -1 || endIndex == -1 { - flog.Fatal("the Coder Enterprise ssh configuration section could not be safely deleted or does not exist") - } - currentConfig = currentConfig[:startIndex-1] + currentConfig[endIndex+len(cmd.endToken)+1:] + err = writeStr(*filepath, currentConfig) + if err != nil { + return xerrors.Errorf("write to ssh config file %q: %v", *filepath, err) + } - err = writeStr(cmd.filepath, currentConfig) - if err != nil { - flog.Fatal("failed to write to ssh config file %q: %v", cmd.filepath, err) + return nil } - return - } + entClient := requireAuth() - entClient := requireAuth() + sshAvailable := isSSHAvailable(ctx) + if !sshAvailable { + return xerrors.New("SSH is disabled or not available for your Coder Enterprise deployment.") + } - sshAvailable := cmd.ensureSSHAvailable(ctx) - if !sshAvailable { - flog.Fatal("SSH is disabled or not available for your Coder Enterprise deployment.") - } + me, err := entClient.Me() + if err != nil { + return xerrors.Errorf("fetch username: %w", err) + } - me, err := entClient.Me() - if err != nil { - flog.Fatal("failed to fetch username: %v", err) - } + envs, err := getEnvs(entClient) + if err != nil { + return err + } + if len(envs) < 1 { + return xerrors.New("no environments found") + } + newConfig, err := makeNewConfigs(me.Username, envs, startToken, startMessage, endToken) + if err != nil { + return xerrors.Errorf("make new ssh configurations: %w", err) + } - envs := getEnvs(entClient) - if len(envs) < 1 { - flog.Fatal("no environments found") - } - newConfig, err := cmd.makeNewConfigs(me.Username, envs) - if err != nil { - flog.Fatal("failed to make new ssh configurations: %v", err) - } + // if we find the old config, remove those chars from the string + if startIndex != -1 && endIndex != -1 { + currentConfig = currentConfig[:startIndex-1] + currentConfig[endIndex+len(endToken)+1:] + } - // if we find the old config, remove those chars from the string - if startIndex != -1 && endIndex != -1 { - currentConfig = currentConfig[:startIndex-1] + currentConfig[endIndex+len(cmd.endToken)+1:] - } + err = writeStr(*filepath, currentConfig+newConfig) + if err != nil { + return xerrors.Errorf("write new configurations to ssh config file %q: %w", filepath, err) + } + err = writeSSHKey(ctx, entClient) + if err != nil { + return xerrors.Errorf("fetch and write ssh key: %w", err) + } - err = writeStr(cmd.filepath, currentConfig+newConfig) - if err != nil { - flog.Fatal("failed to write new configurations to ssh config file %q: %v", cmd.filepath, err) - } - err = writeSSHKey(ctx, entClient) - if err != nil { - flog.Fatal("failed to fetch and write ssh key: %v", err) + fmt.Printf("An auto-generated ssh config was written to %q\n", *filepath) + fmt.Printf("Your private ssh key was written to %q\n", privateKeyFilepath) + fmt.Println("You should now be able to ssh into your environment") + fmt.Printf("For example, try running\n\n\t$ ssh coder.%s\n\n", envs[0].Name) + return nil } - - fmt.Printf("An auto-generated ssh config was written to %q\n", cmd.filepath) - fmt.Printf("Your private ssh key was written to %q\n", privateKeyFilepath) - fmt.Println("You should now be able to ssh into your environment") - fmt.Printf("For example, try running\n\n\t$ ssh coder.%s\n\n", envs[0].Name) } +var ( + privateKeyFilepath = filepath.Join(os.Getenv("HOME"), ".ssh", "coder_enterprise") +) + func writeSSHKey(ctx context.Context, client *entclient.Client) error { key, err := client.SSHKey() if err != nil { return err } - err = ioutil.WriteFile(privateKeyFilepath, []byte(key.PrivateKey), 0400) - if err != nil { - return err - } - return nil + return ioutil.WriteFile(privateKeyFilepath, []byte(key.PrivateKey), 0400) } -func (cmd *configSSHCmd) makeNewConfigs(userName string, envs []entclient.Environment) (string, error) { +func makeNewConfigs(userName string, envs []entclient.Environment, startToken, startMsg, endToken string) (string, error) { hostname, err := configuredHostname() if err != nil { return "", nil } - newConfig := fmt.Sprintf("\n%s\n%s\n\n", cmd.startToken, cmd.startMessage) + newConfig := fmt.Sprintf("\n%s\n%s\n\n", startToken, startMsg) for _, env := range envs { - newConfig += cmd.makeConfig(hostname, userName, env.Name) + newConfig += makeSSHConfig(hostname, userName, env.Name) } - newConfig += fmt.Sprintf("\n%s\n", cmd.endToken) + newConfig += fmt.Sprintf("\n%s\n", endToken) return newConfig, nil } -func (cmd *configSSHCmd) makeConfig(host, userName, envName string) string { +func makeSSHConfig(host, userName, envName string) string { return fmt.Sprintf( `Host coder.%s - HostName %s - User %s-%s - StrictHostKeyChecking no - ConnectTimeout=0 - IdentityFile=%s - ServerAliveInterval 60 - ServerAliveCountMax 3 + HostName %s + User %s-%s + StrictHostKeyChecking no + ConnectTimeout=0 + IdentityFile=%s + ServerAliveInterval 60 + ServerAliveCountMax 3 `, envName, host, userName, envName, privateKeyFilepath) } -func (cmd *configSSHCmd) ensureSSHAvailable(ctx context.Context) bool { +func isSSHAvailable(ctx context.Context) bool { ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() diff --git a/cmd/coder/envs.go b/cmd/coder/envs.go index 9e45df1f..3442bdea 100644 --- a/cmd/coder/envs.go +++ b/cmd/coder/envs.go @@ -1,29 +1,54 @@ package main import ( - "fmt" + "encoding/json" + "os" - "github.com/spf13/pflag" - - "go.coder.com/cli" + "cdr.dev/coder-cli/internal/x/xtabwriter" + "github.com/spf13/cobra" + "golang.org/x/xerrors" ) -type envsCmd struct { -} - -func (cmd envsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "envs", - Desc: "get a list of environments owned by the authenticated user", +func makeEnvsCommand() *cobra.Command { + var outputFmt string + cmd := &cobra.Command{ + Use: "envs", + Short: "Interact with Coder environments", + Long: "Perform operations on the Coder environments owned by the active user.", } -} -func (cmd envsCmd) Run(fl *pflag.FlagSet) { - entClient := requireAuth() - - envs := getEnvs(entClient) - - for _, env := range envs { - fmt.Println(env.Name) + lsCmd := &cobra.Command{ + Use: "ls", + Short: "list all environments owned by the active user", + Long: "List all Coder environments owned by the active user.", + RunE: func(cmd *cobra.Command, args []string) error { + entClient := requireAuth() + envs, err := getEnvs(entClient) + if err != nil { + return err + } + + switch outputFmt { + case "human": + err := xtabwriter.WriteTable(len(envs), func(i int) interface{} { + return envs[i] + }) + if err != nil { + return xerrors.Errorf("write table: %w", err) + } + case "json": + err := json.NewEncoder(os.Stdout).Encode(envs) + if err != nil { + return xerrors.Errorf("write environments as JSON: %w", err) + } + default: + return xerrors.Errorf("unknown --output value %q", outputFmt) + } + return nil + }, } + lsCmd.Flags().StringVarP(&outputFmt, "output", "o", "human", "human | json") + cmd.AddCommand(lsCmd) + + return cmd } diff --git a/cmd/coder/exit.go b/cmd/coder/exit.go deleted file mode 100644 index d4645e62..00000000 --- a/cmd/coder/exit.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - "os" - - "github.com/spf13/pflag" -) - -func exitUsage(fl *pflag.FlagSet) { - fl.Usage() - os.Exit(1) -} diff --git a/cmd/coder/login.go b/cmd/coder/login.go index fef1a38b..ed8cb499 100644 --- a/cmd/coder/login.go +++ b/cmd/coder/login.go @@ -7,40 +7,39 @@ import ( "strings" "sync" + "cdr.dev/coder-cli/internal/config" + "cdr.dev/coder-cli/internal/loginsrv" "github.com/pkg/browser" - "github.com/spf13/pflag" + "github.com/spf13/cobra" + "golang.org/x/xerrors" - "go.coder.com/cli" "go.coder.com/flog" - - "cdr.dev/coder-cli/internal/config" - "cdr.dev/coder-cli/internal/loginsrv" ) -type loginCmd struct { -} - -func (cmd loginCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "login", - Usage: "[Coder Enterprise URL eg. http://my.coder.domain/ ]", - Desc: "authenticate this client for future operations", +func makeLoginCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "login [Coder Enterprise URL eg. http://my.coder.domain/]", + Short: "Authenticate this client for future operations", + Args: cobra.ExactArgs(1), + RunE: login, } + return cmd } -func (cmd loginCmd) Run(fl *pflag.FlagSet) { - rawURL := fl.Arg(0) + +func login(cmd *cobra.Command, args []string) error { + rawURL := args[0] if rawURL == "" || !strings.HasPrefix(rawURL, "http") { - exitUsage(fl) + return xerrors.Errorf("invalid URL") } u, err := url.Parse(rawURL) if err != nil { - flog.Fatal("parse url: %v", err) + return xerrors.Errorf("parse url: %v", err) } listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - flog.Fatal("create login server: %+v", err) + return xerrors.Errorf("create login server: %+v", err) } defer listener.Close() @@ -57,7 +56,7 @@ func (cmd loginCmd) Run(fl *pflag.FlagSet) { (&url.URL{Scheme: u.Scheme, Host: u.Host}).String(), ) if err != nil { - flog.Fatal("write url: %v", err) + return xerrors.Errorf("write url: %v", err) } authURL := url.URL{ @@ -77,7 +76,8 @@ func (cmd loginCmd) Run(fl *pflag.FlagSet) { err = config.Session.Write(srv.Token) srv.TokenCond.L.Unlock() if err != nil { - flog.Fatal("set session: %v", err) + return xerrors.Errorf("set session: %v", err) } flog.Success("logged in") + return nil } diff --git a/cmd/coder/logout.go b/cmd/coder/logout.go index 6120f527..079a2e56 100644 --- a/cmd/coder/logout.go +++ b/cmd/coder/logout.go @@ -3,32 +3,30 @@ package main import ( "os" - "github.com/spf13/pflag" + "cdr.dev/coder-cli/internal/config" + "github.com/spf13/cobra" + "golang.org/x/xerrors" - "go.coder.com/cli" "go.coder.com/flog" - - "cdr.dev/coder-cli/internal/config" ) -type logoutCmd struct { -} - -func (cmd logoutCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "logout", - Desc: "remove local authentication credentials (if any)", +func makeLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Remove local authentication credentials if any exist", + RunE: logout, } } -func (cmd logoutCmd) Run(_ *pflag.FlagSet) { +func logout(_ *cobra.Command, _ []string) error { err := config.Session.Delete() if err != nil { if os.IsNotExist(err) { flog.Info("no active session") - return + return nil } - flog.Fatal("delete session: %v", err) + return xerrors.Errorf("delete session: %w", err) } flog.Success("logged out") + return nil } diff --git a/cmd/coder/main.go b/cmd/coder/main.go index 5680d30d..7346212b 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -1,52 +1,23 @@ package main import ( + "fmt" "log" "net/http" _ "net/http/pprof" "os" + "runtime" "cdr.dev/coder-cli/internal/x/xterminal" - "github.com/spf13/pflag" + "github.com/spf13/cobra" "go.coder.com/flog" - - "go.coder.com/cli" ) var ( - version string = "No version built" + version string = "unknown" ) -type rootCmd struct{} - -func (r *rootCmd) Run(fl *pflag.FlagSet) { - fl.Usage() -} - -func (r *rootCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "coder", - Usage: "[subcommand] [flags]", - Desc: "coder provides a CLI for working with an existing Coder Enterprise installation.", - } -} - -func (r *rootCmd) Subcommands() []cli.Command { - return []cli.Command{ - &envsCmd{}, - &loginCmd{}, - &logoutCmd{}, - &shellCmd{}, - &syncCmd{}, - &urlsCmd{}, - &versionCmd{}, - &configSSHCmd{}, - &usersCmd{}, - &secretsCmd{}, - } -} - func main() { if os.Getenv("PPROF") != "" { go func() { @@ -56,16 +27,82 @@ func main() { stdoutState, err := xterminal.MakeOutputRaw(os.Stdout.Fd()) if err != nil { - flog.Fatal("failed to set output to raw: %v", err) + flog.Fatal("set output to raw: %v", err) } defer xterminal.Restore(os.Stdout.Fd(), stdoutState) - cli.RunRoot(&rootCmd{}) -} + app := &cobra.Command{ + Use: "coder", + Short: "coder provides a CLI for working with an existing Coder Enterprise installation", + Version: fmt.Sprintf("%s %s %s/%s", version, runtime.Version(), runtime.GOOS, runtime.GOARCH), + } -// requireSuccess prints the given message and format args as a fatal error if err != nil -func requireSuccess(err error, msg string, args ...interface{}) { + app.AddCommand( + makeLoginCmd(), + makeLogoutCmd(), + makeShellCmd(), + makeUsersCmd(), + makeConfigSSHCmd(), + makeSecretsCmd(), + makeEnvsCommand(), + makeSyncCmd(), + makeURLCmd(), + completionCmd, + ) + err = app.Execute() if err != nil { - flog.Fatal(msg, args...) + os.Exit(1) } } + +// reference: https://github.com/spf13/cobra/blob/master/shell_completions.md +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate completion script", + Long: `To load completions: + +Bash: + +$ source <(yourprogram completion bash) + +# To load completions for each session, execute once: +Linux: + $ yourprogram completion bash > /etc/bash_completion.d/yourprogram +MacOS: + $ yourprogram completion bash > /usr/local/etc/bash_completion.d/yourprogram + +Zsh: + +# If shell completion is not already enabled in your environment you will need +# to enable it. You can execute the following once: + +$ echo "autoload -U compinit; compinit" >> ~/.zshrc + +# To load completions for each session, execute once: +$ yourprogram completion zsh > "${fpath[1]}/_yourprogram" + +# You will need to start a new shell for this setup to take effect. + +Fish: + +$ yourprogram completion fish | source + +# To load completions for each session, execute once: +$ yourprogram completion fish > ~/.config/fish/completions/yourprogram.fish +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.ExactValidArgs(1), + Run: func(cmd *cobra.Command, args []string) { + switch args[0] { + case "bash": + cmd.Root().GenBashCompletion(os.Stdout) + case "zsh": + cmd.Root().GenZshCompletion(os.Stdout) + case "fish": + cmd.Root().GenFishCompletion(os.Stdout, true) + case "powershell": + cmd.Root().GenPowerShellCompletion(os.Stdout) + } + }, +} diff --git a/cmd/coder/secrets.go b/cmd/coder/secrets.go index a07df2de..27bade21 100644 --- a/cmd/coder/secrets.go +++ b/cmd/coder/secrets.go @@ -7,208 +7,191 @@ import ( "cdr.dev/coder-cli/internal/entclient" "cdr.dev/coder-cli/internal/x/xtabwriter" - "cdr.dev/coder-cli/internal/x/xvalidate" "github.com/manifoldco/promptui" - "github.com/spf13/pflag" + "github.com/spf13/cobra" "golang.org/x/xerrors" "go.coder.com/flog" - - "go.coder.com/cli" -) - -var ( - _ cli.FlaggedCommand = secretsCmd{} - _ cli.ParentCommand = secretsCmd{} - - _ cli.FlaggedCommand = &listSecretsCmd{} - _ cli.FlaggedCommand = &createSecretCmd{} ) -type secretsCmd struct { -} - -func (cmd secretsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "secrets", - Usage: "[subcommand]", - Desc: "interact with secrets", +func makeSecretsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "secrets", + Short: "Interact with Coder Secrets", + Long: "Interact with secrets objects owned by the active user.", } + cmd.AddCommand( + &cobra.Command{ + Use: "ls", + Short: "List all secrets owned by the active user", + RunE: listSecrets, + }, + makeCreateSecret(), + &cobra.Command{ + Use: "rm [...secret_name]", + Short: "Remove one or more secrets by name", + Args: cobra.MinimumNArgs(1), + RunE: removeSecrets, + Example: "coder secrets rm mysql-password mysql-user", + }, + &cobra.Command{ + Use: "view [secret_name]", + Short: "View a secret by name", + Args: cobra.ExactArgs(1), + RunE: viewSecret, + Example: "coder secrets view mysql-password", + }, + ) + return cmd } -func (cmd secretsCmd) Run(fl *pflag.FlagSet) { - exitUsage(fl) -} - -func (cmd secretsCmd) RegisterFlags(fl *pflag.FlagSet) {} +func makeCreateSecret() *cobra.Command { + var ( + fromFile string + fromLiteral string + fromPrompt bool + description string + ) -func (cmd secretsCmd) Subcommands() []cli.Command { - return []cli.Command{ - &listSecretsCmd{}, - &viewSecretsCmd{}, - &createSecretCmd{}, - &deleteSecretsCmd{}, + cmd := &cobra.Command{ + Use: "create [secret_name]", + Short: "Create a new secret", + Long: "Create a new secret object to store application secrets and access them securely from within your environments.", + Example: `coder secrets create mysql-password --from-literal 123password +coder secrets create mysql-password --from-prompt +coder secrets create aws-credentials --from-file ./credentials.json`, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return xerrors.Errorf("[secret_name] is a required argument") + } + if fromPrompt && (fromLiteral != "" || fromFile != "") { + return xerrors.Errorf("--from-prompt cannot be set along with --from-file or --from-literal") + } + if fromLiteral != "" && fromFile != "" { + return xerrors.Errorf("--from-literal and --from-file cannot both be set") + } + if !fromPrompt && fromFile == "" && fromLiteral == "" { + return xerrors.Errorf("one of [--from-literal, --from-file, --from-prompt] is required") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + var ( + client = requireAuth() + name = args[0] + value string + err error + ) + if fromLiteral != "" { + value = fromLiteral + } else if fromFile != "" { + contents, err := ioutil.ReadFile(fromFile) + if err != nil { + return xerrors.Errorf("read file: %w", err) + } + value = string(contents) + } else { + prompt := promptui.Prompt{ + Label: "value", + Mask: '*', + Validate: func(s string) error { + if len(s) < 1 { + return xerrors.Errorf("a length > 0 is required") + } + return nil + }, + } + value, err = prompt.Run() + if err != nil { + return xerrors.Errorf("prompt for value: %w", err) + } + } + + err = client.InsertSecret(entclient.InsertSecretReq{ + Name: name, + Value: value, + Description: description, + }) + if err != nil { + return xerrors.Errorf("insert secret: %w", err) + } + return nil + }, } -} -type listSecretsCmd struct{} + cmd.Flags().StringVar(&fromFile, "from-file", "", "a file from which to read the value of the secret") + cmd.Flags().StringVar(&fromLiteral, "from-literal", "", "the value of the secret") + cmd.Flags().BoolVar(&fromPrompt, "from-prompt", false, "enter the secret value through a terminal prompt") + cmd.Flags().StringVar(&description, "description", "", "a description of the secret") -func (cmd *listSecretsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "ls", - Desc: "list all secrets", - } + return cmd } -func (cmd *listSecretsCmd) Run(fl *pflag.FlagSet) { +func listSecrets(cmd *cobra.Command, _ []string) error { client := requireAuth() secrets, err := client.Secrets() - requireSuccess(err, "failed to get secrets: %v", err) + if err != nil { + return xerrors.Errorf("get secrets: %w", err) + } if len(secrets) < 1 { flog.Info("No secrets found") - return + return nil } - w := xtabwriter.NewWriter() - _, err = fmt.Fprintln(w, xtabwriter.StructFieldNames(secrets[0])) - requireSuccess(err, "failed to write: %v", err) - for _, s := range secrets { + err = xtabwriter.WriteTable(len(secrets), func(i int) interface{} { + s := secrets[i] s.Value = "******" // value is omitted from bulk responses - - _, err = fmt.Fprintln(w, xtabwriter.StructValues(s)) - requireSuccess(err, "failed to write: %v", err) - } - err = w.Flush() - requireSuccess(err, "failed to flush writer: %v", err) -} - -func (cmd *listSecretsCmd) RegisterFlags(fl *pflag.FlagSet) {} - -type viewSecretsCmd struct{} - -func (cmd viewSecretsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "view", - Usage: "[secret_name]", - Desc: "view a secret", + return s + }) + if err != nil { + return xerrors.Errorf("write table of secrets: %w", err) } + return nil } -func (cmd viewSecretsCmd) Run(fl *pflag.FlagSet) { +func viewSecret(_ *cobra.Command, args []string) error { var ( client = requireAuth() - name = fl.Arg(0) + name = args[0] ) if name == "" { - exitUsage(fl) + return xerrors.New("[name] is a required argument") } secret, err := client.SecretByName(name) - requireSuccess(err, "failed to get secret by name: %v", err) - - _, err = fmt.Fprintln(os.Stdout, secret.Value) - requireSuccess(err, "failed to write: %v", err) -} - -type createSecretCmd struct { - description string - fromFile string - fromLiteral string - fromPrompt bool -} - -func (cmd *createSecretCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "create", - Usage: `[secret_name] [...flags]`, - Desc: "create a new secret", + if err != nil { + return xerrors.Errorf("get secret by name: %w", err) } -} -func (cmd *createSecretCmd) Validate(fl *pflag.FlagSet) (e []error) { - if cmd.fromPrompt && (cmd.fromLiteral != "" || cmd.fromFile != "") { - e = append(e, xerrors.Errorf("--from-prompt cannot be set along with --from-file or --from-literal")) - } - if cmd.fromLiteral != "" && cmd.fromFile != "" { - e = append(e, xerrors.Errorf("--from-literal and --from-file cannot both be set")) - } - if !cmd.fromPrompt && cmd.fromFile == "" && cmd.fromLiteral == "" { - e = append(e, xerrors.Errorf("one of [--from-literal, --from-file, --from-prompt] is required")) + _, err = fmt.Fprintln(os.Stdout, secret.Value) + if err != nil { + return xerrors.Errorf("write secret value: %w", err) } - return e + return nil } -func (cmd *createSecretCmd) Run(fl *pflag.FlagSet) { +func removeSecrets(_ *cobra.Command, args []string) error { var ( client = requireAuth() - name = fl.Arg(0) - value string - err error ) - if name == "" { - exitUsage(fl) - } - xvalidate.Validate(fl, cmd) - - if cmd.fromLiteral != "" { - value = cmd.fromLiteral - } else if cmd.fromFile != "" { - contents, err := ioutil.ReadFile(cmd.fromFile) - requireSuccess(err, "failed to read file: %v", err) - value = string(contents) - } else { - prompt := promptui.Prompt{ - Label: "value", - Mask: '*', - Validate: func(s string) error { - if len(s) < 1 { - return xerrors.Errorf("a length > 0 is required") - } - return nil - }, - } - value, err = prompt.Run() - requireSuccess(err, "failed to prompt for value: %v", err) + if len(args) < 1 { + return xerrors.New("[...secret_name] is a required argument") } - err = client.InsertSecret(entclient.InsertSecretReq{ - Name: name, - Value: value, - Description: cmd.description, - }) - requireSuccess(err, "failed to insert secret: %v", err) -} - -func (cmd *createSecretCmd) RegisterFlags(fl *pflag.FlagSet) { - fl.StringVar(&cmd.fromFile, "from-file", "", "specify a file from which to read the value of the secret") - fl.StringVar(&cmd.fromLiteral, "from-literal", "", "specify the value of the secret") - fl.BoolVar(&cmd.fromPrompt, "from-prompt", false, "specify the value of the secret through a prompt") - fl.StringVar(&cmd.description, "description", "", "specify a description of the secret") -} - -type deleteSecretsCmd struct{} - -func (cmd *deleteSecretsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "rm", - Usage: "[secret_name]", - Desc: "remove a secret", + errorSeen := false + for _, n := range args { + err := client.DeleteSecretByName(n) + if err != nil { + flog.Error("failed to delete secret %q: %v", n, err) + errorSeen = true + } else { + flog.Success("successfully deleted secret %q", n) + } } -} - -func (cmd *deleteSecretsCmd) Run(fl *pflag.FlagSet) { - var ( - client = requireAuth() - name = fl.Arg(0) - ) - if name == "" { - exitUsage(fl) + if errorSeen { + os.Exit(1) } - - err := client.DeleteSecretByName(name) - requireSuccess(err, "failed to delete secret: %v", err) - - flog.Info("Successfully deleted secret %q", name) + return nil } diff --git a/cmd/coder/shell.go b/cmd/coder/shell.go index bc1c6ef4..0012c338 100644 --- a/cmd/coder/shell.go +++ b/cmd/coder/shell.go @@ -7,48 +7,60 @@ import ( "strings" "time" - "github.com/spf13/pflag" + "cdr.dev/coder-cli/internal/activity" + "cdr.dev/coder-cli/internal/x/xterminal" + "cdr.dev/wsep" + "github.com/spf13/cobra" "golang.org/x/crypto/ssh/terminal" "golang.org/x/time/rate" "golang.org/x/xerrors" "nhooyr.io/websocket" - "go.coder.com/cli" "go.coder.com/flog" - - "cdr.dev/coder-cli/internal/activity" - "cdr.dev/coder-cli/internal/x/xterminal" - "cdr.dev/wsep" ) -type shellCmd struct{} +func getEnvsForCompletion() []string { + // TODO(@cmoog): Enable this if speed issue can be resolved. Otherwise, all commands will take > 1 second. + return nil -func (cmd *shellCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "sh", - Usage: " []", - Desc: "execute a remote command on the environment\nIf no command is specified, the default shell is opened.", - RawArgs: true, + var envNames []string + client, err := newClient() + if err != nil { + return envNames } + envs, err := getEnvs(client) + if err != nil { + return envNames + } + for _, e := range envs { + envNames = append(envNames, e.Name) + } + return envNames } -type resizeEvent struct { - height, width uint16 +func makeShellCmd() *cobra.Command { + return &cobra.Command{ + Use: "sh [environment_name] []", + Short: "Open a shell and execute commands in a Coder environment", + Long: "Execute a remote command on the environment\\nIf no command is specified, the default shell is opened.", + Args: cobra.MinimumNArgs(1), + DisableFlagParsing: true, + ValidArgs: getEnvsForCompletion(), + RunE: shell, + Example: "coder sh backend-env", + } } -func (cmd *shellCmd) Run(fl *pflag.FlagSet) { - if len(fl.Args()) < 1 { - exitUsage(fl) - } +func shell(_ *cobra.Command, cmdArgs []string) error { var ( - envName = fl.Arg(0) + envName = cmdArgs[0] ctx = context.Background() ) command := "sh" args := []string{"-c"} - if len(fl.Args()) > 1 { - args = append(args, strings.Join(fl.Args()[1:], " ")) + if len(cmdArgs) > 1 { + args = append(args, strings.Join(cmdArgs[1:], " ")) } else { // Bring user into shell if no command is specified. args = append(args, "exec $(getent passwd $(whoami) | awk -F: '{ print $7 }')") @@ -59,8 +71,9 @@ func (cmd *shellCmd) Run(fl *pflag.FlagSet) { os.Exit(exitErr.Code) } if err != nil { - flog.Fatal("run command: %v", err) + return xerrors.Errorf("run command: %w", err) } + return nil } func sendResizeEvents(ctx context.Context, termfd uintptr, process wsep.Process) { @@ -85,8 +98,11 @@ func sendResizeEvents(ctx context.Context, termfd uintptr, process wsep.Process) func runCommand(ctx context.Context, envName string, command string, args []string) error { var ( entClient = requireAuth() - env = findEnv(entClient, envName) ) + env, err := findEnv(entClient, envName) + if err != nil { + return err + } termfd := os.Stdout.Fd() @@ -102,7 +118,7 @@ func runCommand(ctx context.Context, envName string, command string, args []stri ctx, cancel := context.WithCancel(ctx) defer cancel() - conn, err := entClient.DialWsep(ctx, env) + conn, err := entClient.DialWsep(ctx, *env) if err != nil { return err } @@ -182,7 +198,7 @@ func heartbeat(ctx context.Context, c *websocket.Conn, interval time.Duration) { case <-ticker.C: err := c.Ping(ctx) if err != nil { - flog.Error("failed to ping websocket: %v", err) + flog.Fatal("\nFailed to ping websocket: %v, exiting...", err) } } } diff --git a/cmd/coder/sync.go b/cmd/coder/sync.go index 601fddd7..20aed14c 100644 --- a/cmd/coder/sync.go +++ b/cmd/coder/sync.go @@ -8,31 +8,26 @@ import ( "path/filepath" "strings" - "github.com/spf13/pflag" + "cdr.dev/coder-cli/internal/sync" + "github.com/spf13/cobra" + "golang.org/x/xerrors" - "go.coder.com/cli" "go.coder.com/flog" - - "cdr.dev/coder-cli/internal/sync" ) -type syncCmd struct { - init bool -} - -func (cmd *syncCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "sync", - Usage: "[local directory] [:]", - Desc: "establish a one way directory sync to a remote environment", +func makeSyncCmd() *cobra.Command { + var init bool + cmd := &cobra.Command{ + Use: "sync [local directory] [:]", + Short: "Establish a one way directory sync to a Coder environment", + Args: cobra.ExactArgs(2), + RunE: makeRunSync(&init), } + cmd.Flags().BoolVar(&init, "init", false, "do initial transfer and exit") + return cmd } -func (cmd *syncCmd) RegisterFlags(fl *pflag.FlagSet) { - fl.BoolVarP(&cmd.init, "init", "i", false, "do initial transfer and exit") -} - -// version returns local rsync protocol version as a string. +// rsyncVersion returns local rsync protocol version as a string. func rsyncVersion() string { cmd := exec.Command("rsync", "--version") out, err := cmd.CombinedOutput() @@ -49,63 +44,65 @@ func rsyncVersion() string { return versionString[1] } -func (cmd *syncCmd) Run(fl *pflag.FlagSet) { - var ( - local = fl.Arg(0) - remote = fl.Arg(1) - ) - if local == "" || remote == "" { - exitUsage(fl) - } - - entClient := requireAuth() - - info, err := os.Stat(local) - if err != nil { - flog.Fatal("%v", err) - } - if !info.IsDir() { - flog.Fatal("%s must be a directory", local) - } - - remoteTokens := strings.SplitN(remote, ":", 2) - if len(remoteTokens) != 2 { - flog.Fatal("remote misformatted") - } - var ( - envName = remoteTokens[0] - remoteDir = remoteTokens[1] - ) - - env := findEnv(entClient, envName) - - absLocal, err := filepath.Abs(local) - if err != nil { - flog.Fatal("make abs path out of %v: %v", local, absLocal) - } - - s := sync.Sync{ - Init: cmd.init, - Env: env, - RemoteDir: remoteDir, - LocalDir: absLocal, - Client: entClient, - } - - localVersion := rsyncVersion() - remoteVersion, rsyncErr := s.Version() - - if rsyncErr != nil { - flog.Info("Unable to determine remote rsync version. Proceeding cautiously.") - } else if localVersion != remoteVersion { - flog.Fatal("rsync protocol mismatch: local = %v, remote = %v", localVersion, rsyncErr) - } - - for err == nil || err == sync.ErrRestartSync { - err = s.Run() - } - - if err != nil { - flog.Fatal("%v", err) +func makeRunSync(init *bool) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + var ( + local = args[0] + remote = args[1] + ) + + entClient := requireAuth() + + info, err := os.Stat(local) + if err != nil { + return err + } + if !info.IsDir() { + return xerrors.Errorf("%s must be a directory", local) + } + + remoteTokens := strings.SplitN(remote, ":", 2) + if len(remoteTokens) != 2 { + flog.Fatal("remote misformatted") + } + var ( + envName = remoteTokens[0] + remoteDir = remoteTokens[1] + ) + + env, err := findEnv(entClient, envName) + if err != nil { + return err + } + + absLocal, err := filepath.Abs(local) + if err != nil { + flog.Fatal("make abs path out of %v: %v", local, absLocal) + } + + s := sync.Sync{ + Init: *init, + Env: *env, + RemoteDir: remoteDir, + LocalDir: absLocal, + Client: entClient, + } + + localVersion := rsyncVersion() + remoteVersion, rsyncErr := s.Version() + + if rsyncErr != nil { + flog.Info("Unable to determine remote rsync version. Proceeding cautiously.") + } else if localVersion != remoteVersion { + flog.Fatal("rsync protocol mismatch: local = %v, remote = %v", localVersion, rsyncErr) + } + + for err == nil || err == sync.ErrRestartSync { + err = s.Run() + } + if err != nil { + return err + } + return nil } } diff --git a/cmd/coder/urls.go b/cmd/coder/urls.go index 582fbdb1..01a3bd7b 100644 --- a/cmd/coder/urls.go +++ b/cmd/coder/urls.go @@ -8,43 +8,52 @@ import ( "regexp" "strconv" "strings" - "text/tabwriter" - "github.com/spf13/pflag" + "cdr.dev/coder-cli/internal/x/xtabwriter" + "github.com/spf13/cobra" + "golang.org/x/xerrors" - "go.coder.com/cli" "go.coder.com/flog" ) -type urlsCmd struct{} - -func (cmd *urlsCmd) Subcommands() []cli.Command { - return []cli.Command{ - &listSubCmd{}, - &createSubCmd{}, - &delSubCmd{}, +func makeURLCmd() *cobra.Command { + var outputFmt string + cmd := &cobra.Command{ + Use: "urls", + Short: "Interact with environment DevURLs", } -} + lsCmd := &cobra.Command{ + Use: "ls [environment_name]", + Short: "List all DevURLs for an environment", + Args: cobra.ExactArgs(1), + ValidArgs: getEnvsForCompletion(), + RunE: makeListDevURLs(&outputFmt), + } + lsCmd.Flags().StringVarP(&outputFmt, "output", "o", "human", "human|json") -func (cmd urlsCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "urls", - Usage: "[subcommand] ", - Desc: "interact with environment devurls", + rmCmd := &cobra.Command{ + Use: "rm [environment_name] [port]", + Args: cobra.ExactArgs(2), + Short: "Remove a dev url", + RunE: removeDevURL, } -} -func (cmd urlsCmd) Run(fl *pflag.FlagSet) { - exitUsage(fl) + cmd.AddCommand( + lsCmd, + rmCmd, + makeCreateDevURL(), + ) + + return cmd } // DevURL is the parsed json response record for a devURL from cemanager type DevURL struct { - ID string `json:"id"` + ID string `json:"id" tab:"-"` URL string `json:"url"` Port int `json:"port"` + Name string `json:"name" tab:"-"` Access string `json:"access"` - Name string `json:"name"` } var urlAccessLevel = map[string]string{ @@ -78,65 +87,101 @@ func accessLevelIsValid(level string) bool { return ok } -type listSubCmd struct { - outputFmt string -} - // Run gets the list of active devURLs from the cemanager for the // specified environment and outputs info to stdout. -func (sub listSubCmd) Run(fl *pflag.FlagSet) { - envName := fl.Arg(0) - devURLs := urlList(envName) - - if len(devURLs) == 0 { - return - } +func makeListDevURLs(outputFmt *string) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + envName := args[0] + devURLs, err := urlList(envName) + if err != nil { + return err + } - switch sub.outputFmt { - case "human": - w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.TabIndent) - for _, devURL := range devURLs { - fmt.Fprintf(w, "%s\t%d\t%s\n", devURL.URL, devURL.Port, devURL.Access) + switch *outputFmt { + case "human": + err := xtabwriter.WriteTable(len(devURLs), func(i int) interface{} { + return devURLs[i] + }) + if err != nil { + return xerrors.Errorf("write table: %w", err) + } + case "json": + err := json.NewEncoder(os.Stdout).Encode(devURLs) + if err != nil { + return xerrors.Errorf("encode DevURLs as json: %w", err) + } + default: + return xerrors.Errorf("unknown --output value %q", *outputFmt) } - err := w.Flush() - requireSuccess(err, "failed to flush writer: %v", err) - case "json": - err := json.NewEncoder(os.Stdout).Encode(devURLs) - requireSuccess(err, "failed to encode devurls to json: %v", err) - default: - exitUsage(fl) + return nil } } -func (sub *listSubCmd) RegisterFlags(fl *pflag.FlagSet) { - fl.StringVarP(&sub.outputFmt, "output", "o", "human", "output format (human | json)") -} - -func (sub *listSubCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "ls", - Usage: " ", - Desc: "list all devurls for a given environment", +func makeCreateDevURL() *cobra.Command { + var ( + access string + urlname string + ) + cmd := &cobra.Command{ + Use: "create [env_name] [port] [--access ] [--name ]", + Short: "Create a new devurl for an environment", + Aliases: []string{"edit"}, + Args: cobra.ExactArgs(2), + // Run creates or updates a devURL + RunE: func(cmd *cobra.Command, args []string) error { + var ( + envName = args[0] + port = args[1] + ) + + portNum, err := validatePort(port) + if err != nil { + return err + } + + access = strings.ToUpper(access) + if !accessLevelIsValid(access) { + return xerrors.Errorf("invalid access level %q", access) + } + + if urlname != "" && !devURLNameValidRx.MatchString(urlname) { + return xerrors.New("update devurl: name must be < 64 chars in length, begin with a letter and only contain letters or digits.") + } + entClient := requireAuth() + + env, err := findEnv(entClient, envName) + if err != nil { + return err + } + + urls, err := urlList(envName) + if err != nil { + return err + } + + urlID, found := devURLID(portNum, urls) + if found { + flog.Info("Updating devurl for port %v", port) + err := entClient.UpdateDevURL(env.ID, urlID, portNum, urlname, access) + if err != nil { + return xerrors.Errorf("update DevURL: %w", err) + } + } else { + flog.Info("Adding devurl for port %v", port) + err := entClient.InsertDevURL(env.ID, portNum, urlname, access) + if err != nil { + return xerrors.Errorf("insert DevURL: %w", err) + } + } + return nil + }, } -} -type createSubCmd struct { - access string - urlname string -} + cmd.Flags().StringVar(&access, "access", "private", "Set DevURL access to [private | org | authed | public]") + cmd.Flags().StringVar(&urlname, "name", "", "DevURL name") + _ = cmd.MarkFlagRequired("name") -func (sub *createSubCmd) RegisterFlags(fl *pflag.FlagSet) { - fl.StringVarP(&sub.access, "access", "a", "private", "[private | org | authed | public] set devurl access") - fl.StringVarP(&sub.urlname, "name", "n", "", "devurl name") -} - -func (sub createSubCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "create", - Usage: " [--access ] [--name ]", - Aliases: []string{"edit"}, - Desc: "create or update a devurl for external access", - } + return cmd } // devURLNameValidRx is the regex used to validate devurl names specified @@ -144,60 +189,6 @@ func (sub createSubCmd) Spec() cli.CommandSpec { // consist solely of letters and digits, with a max length of 64 chars. var devURLNameValidRx = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9]{0,63}$") -// Run creates or updates a devURL, specified by env ID and port -// (fl.Arg(0) and fl.Arg(1)), with access level (fl.Arg(2)) on -// the cemanager. -func (sub createSubCmd) Run(fl *pflag.FlagSet) { - envName := fl.Arg(0) - port := fl.Arg(1) - name := fl.Arg(2) - access := fl.Arg(3) - - if envName == "" { - exitUsage(fl) - } - - portNum, err := validatePort(port) - if err != nil { - exitUsage(fl) - } - - access = strings.ToUpper(sub.access) - if !accessLevelIsValid(access) { - exitUsage(fl) - } - - name = sub.urlname - if name != "" && !devURLNameValidRx.MatchString(name) { - flog.Fatal("update devurl: name must be < 64 chars in length, begin with a letter and only contain letters or digits.") - return - } - entClient := requireAuth() - - env := findEnv(entClient, envName) - - urlID, found := devURLID(portNum, urlList(envName)) - if found { - flog.Info("Updating devurl for port %v", port) - err := entClient.UpdateDevURL(env.ID, urlID, portNum, name, access) - requireSuccess(err, "update devurl: %s", err) - } else { - flog.Info("Adding devurl for port %v", port) - err := entClient.InsertDevURL(env.ID, portNum, name, access) - requireSuccess(err, "insert devurl: %s", err) - } -} - -type delSubCmd struct{} - -func (sub delSubCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "rm", - Usage: " ", - Desc: "remove a devurl", - } -} - // devURLID returns the ID of a devURL, given the env name and port // from a list of DevURL records. // ("", false) is returned if no match is found. @@ -211,54 +202,70 @@ func devURLID(port int, urls []DevURL) (string, bool) { } // Run deletes a devURL, specified by env ID and port, from the cemanager. -func (sub delSubCmd) Run(fl *pflag.FlagSet) { - envName := fl.Arg(0) - port := fl.Arg(1) - - if envName == "" { - exitUsage(fl) - } +func removeDevURL(cmd *cobra.Command, args []string) error { + var ( + envName = args[0] + port = args[1] + ) portNum, err := validatePort(port) if err != nil { - exitUsage(fl) + return xerrors.Errorf("validate port: %w", err) } entClient := requireAuth() - env := findEnv(entClient, envName) + env, err := findEnv(entClient, envName) + if err != nil { + return err + } - urlID, found := devURLID(portNum, urlList(envName)) + urls, err := urlList(envName) + if err != nil { + return err + } + + urlID, found := devURLID(portNum, urls) if found { flog.Info("Deleting devurl for port %v", port) } else { - flog.Fatal("No devurl found for port %v", port) + return xerrors.Errorf("No devurl found for port %v", port) } err = entClient.DelDevURL(env.ID, urlID) - requireSuccess(err, "delete devurl: %s", err) + if err != nil { + return xerrors.Errorf("delete DevURL: %w", err) + } + return nil } // urlList returns the list of active devURLs from the cemanager. -func urlList(envName string) []DevURL { +func urlList(envName string) ([]DevURL, error) { entClient := requireAuth() - env := findEnv(entClient, envName) + env, err := findEnv(entClient, envName) + if err != nil { + return nil, err + } reqString := "%s/api/environments/%s/devurls?session_token=%s" reqURL := fmt.Sprintf(reqString, entClient.BaseURL, env.ID, entClient.Token) resp, err := http.Get(reqURL) - requireSuccess(err, "%v", err) + if err != nil { + return nil, err + } defer resp.Body.Close() if resp.StatusCode != 200 { - flog.Fatal("non-success status code: %d", resp.StatusCode) + return nil, xerrors.Errorf("non-success status code: %d", resp.StatusCode) } dec := json.NewDecoder(resp.Body) devURLs := make([]DevURL, 0) err = dec.Decode(&devURLs) - requireSuccess(err, "%v", err) + if err != nil { + return nil, err + } - return devURLs + return devURLs, nil } diff --git a/cmd/coder/users.go b/cmd/coder/users.go index e050edfb..bbfb08b8 100644 --- a/cmd/coder/users.go +++ b/cmd/coder/users.go @@ -2,84 +2,58 @@ package main import ( "encoding/json" - "fmt" "os" "cdr.dev/coder-cli/internal/x/xtabwriter" - "cdr.dev/coder-cli/internal/x/xvalidate" - "github.com/spf13/pflag" - - "go.coder.com/cli" + "github.com/spf13/cobra" + "golang.org/x/xerrors" ) -type usersCmd struct { -} - -func (cmd usersCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "users", - Usage: "[subcommand] ", - Desc: "interact with user accounts", +func makeUsersCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "users", + Short: "Interact with Coder user accounts", } -} -func (cmd usersCmd) Run(fl *pflag.FlagSet) { - exitUsage(fl) -} - -func (cmd *usersCmd) Subcommands() []cli.Command { - return []cli.Command{ - &listCmd{}, + var outputFmt string + lsCmd := &cobra.Command{ + Use: "ls", + Short: "list all user accounts", + Example: `coder users ls -o json +coder users ls -o json | jq .[] | jq -r .email`, + RunE: listUsers(&outputFmt), } -} + lsCmd.Flags().StringVarP(&outputFmt, "output", "o", "human", "human | json") -type listCmd struct { - outputFmt string + cmd.AddCommand(lsCmd) + return cmd } -func (cmd *listCmd) Run(fl *pflag.FlagSet) { - xvalidate.Validate(fl, cmd) - entClient := requireAuth() +func listUsers(outputFmt *string) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + entClient := requireAuth() - users, err := entClient.Users() - requireSuccess(err, "failed to get users: %v", err) - - switch cmd.outputFmt { - case "human": - w := xtabwriter.NewWriter() - if len(users) > 0 { - _, err = fmt.Fprintln(w, xtabwriter.StructFieldNames(users[0])) - requireSuccess(err, "failed to write: %v", err) - } - for _, u := range users { - _, err = fmt.Fprintln(w, xtabwriter.StructValues(u)) - requireSuccess(err, "failed to write: %v", err) + users, err := entClient.Users() + if err != nil { + return xerrors.Errorf("get users: %w", err) } - err = w.Flush() - requireSuccess(err, "failed to flush writer: %v", err) - case "json": - err = json.NewEncoder(os.Stdout).Encode(users) - requireSuccess(err, "failed to encode users to json: %v", err) - default: - exitUsage(fl) - } -} - -func (cmd *listCmd) RegisterFlags(fl *pflag.FlagSet) { - fl.StringVarP(&cmd.outputFmt, "output", "o", "human", "output format (human | json)") -} -func (cmd *listCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "ls", - Usage: "", - Desc: "list all users", - } -} - -func (cmd *listCmd) Validate(fl *pflag.FlagSet) (e []error) { - if !(cmd.outputFmt == "json" || cmd.outputFmt == "human") { - e = append(e, fmt.Errorf(`--output must be "json" or "human"`)) + switch *outputFmt { + case "human": + err := xtabwriter.WriteTable(len(users), func(i int) interface{} { + return users[i] + }) + if err != nil { + return xerrors.Errorf("write table: %w", err) + } + case "json": + err = json.NewEncoder(os.Stdout).Encode(users) + if err != nil { + return xerrors.Errorf("encode users as json: %w", err) + } + default: + return xerrors.New("unknown value for --output") + } + return nil } - return e } diff --git a/cmd/coder/version.go b/cmd/coder/version.go deleted file mode 100644 index a1825843..00000000 --- a/cmd/coder/version.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import ( - "fmt" - "runtime" - - "github.com/spf13/pflag" - - "go.coder.com/cli" -) - -type versionCmd struct{} - -func (versionCmd) Spec() cli.CommandSpec { - return cli.CommandSpec{ - Name: "version", - Usage: "", - Desc: "print the currently installed CLI version", - } -} - -func (versionCmd) Run(fl *pflag.FlagSet) { - fmt.Println( - version, - runtime.Version(), - runtime.GOOS+"/"+runtime.GOARCH, - ) -} diff --git a/go.mod b/go.mod index c32da465..6413afbc 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,8 @@ require ( github.com/mattn/go-colorable v0.1.6 // indirect github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/rjeczalik/notify v0.9.2 - github.com/spf13/pflag v1.0.5 - go.coder.com/cli v0.5.0 + github.com/spf13/cobra v1.0.0 + github.com/stretchr/testify v1.6.1 // indirect go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512 golang.org/x/crypto v0.0.0-20200422194213-44a606286825 golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a diff --git a/go.sum b/go.sum index 8ba4aa6d..99efd17c 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,7 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= @@ -32,7 +33,13 @@ github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUS github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA= github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= @@ -40,6 +47,13 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= @@ -48,6 +62,8 @@ github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= @@ -57,11 +73,16 @@ github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= @@ -70,14 +91,18 @@ github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD87 github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 h1:uHTyIjqVhYRhLbJ8nIiOJHkEZZ+5YoOsAbD3sk82NiE= github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -108,24 +133,35 @@ github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAk github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.10.8 h1:eLeJ3dr/Y9+XRfJT4l+8ZjmtB5RPJhucH2HeCV5+IZY= github.com/klauspost/compress v1.10.8/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -135,6 +171,7 @@ github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/manifoldco/promptui v0.7.0 h1:3l11YT8tm9MnwGFQ4kETwkzpAwY2Jt9lCrumCUW4+z4= github.com/manifoldco/promptui v0.7.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= @@ -149,50 +186,86 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98= github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rjeczalik/notify v0.9.2 h1:MiTWrPj55mNDHEiIX5YUSKefw/+lCQVoAFmD6oQm5w8= github.com/rjeczalik/notify v0.9.2/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= go.coder.com/cli v0.4.0 h1:PruDGwm/CPFndyK/eMowZG3vzg5CgohRWeXWCTr3zi8= go.coder.com/cli v0.4.0/go.mod h1:hRTOURCR3LJF1FRW9arecgrzX+AHG7mfYMwThPIgq+w= -go.coder.com/cli v0.5.0 h1:7W9ECtZdVKaAc0Oe2uk5J/c0LCtsWufQz4NeX6YwP0g= -go.coder.com/cli v0.5.0/go.mod h1:h6091Eox0VdgJw2CDBvTyx7SnhduTm8qYM2bR2pewls= go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512 h1:DjCS6dRQh+1PlfiBmnabxfdrzenb0tAwJqFxDEH/s9g= go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512/go.mod h1:83JsYgXYv0EOaXjIMnaZ1Fl6ddNB3fJnDZ/8845mUJ8= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2 h1:75k/FF0Q2YM8QYo07VPddOLBslDt1MZOdEslOHvmzAs= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -219,12 +292,15 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= @@ -240,7 +316,10 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -265,6 +344,7 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -306,14 +386,19 @@ google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1 h1:aQktFqmDE2yjveX google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1 h1:wdKvqQk7IttEw92GoRyKG2IDrUIpgpj6H6m81yfeMW0= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/entclient/env.go b/internal/entclient/env.go index 11a806c5..508f438f 100644 --- a/internal/entclient/env.go +++ b/internal/entclient/env.go @@ -4,13 +4,33 @@ import ( "context" "time" + "cdr.dev/coder-cli/internal/x/xjson" "nhooyr.io/websocket" ) // Environment describes a Coder environment type Environment struct { - Name string `json:"name"` - ID string `json:"id"` + ID string `json:"id" tab:"-"` + Name string `json:"name"` + ImageID string `json:"image_id" tab:"-"` + ImageTag string `json:"image_tag"` + OrganizationID string `json:"organization_id" tab:"-"` + UserID string `json:"user_id" tab:"-"` + LastBuiltAt time.Time `json:"last_built_at" tab:"-"` + CPUCores float32 `json:"cpu_cores"` + MemoryGB int `json:"memory_gb"` + DiskGB int `json:"disk_gb"` + GPUs int `json:"gpus"` + Updating bool `json:"updating"` + RebuildMessages []struct { + Text string `json:"text"` + Required bool `json:"required"` + } `json:"rebuild_messages" tab:"-"` + CreatedAt time.Time `json:"created_at" tab:"-"` + UpdatedAt time.Time `json:"updated_at" tab:"-"` + LastOpenedAt time.Time `json:"last_opened_at" tab:"-"` + LastConnectionAt time.Time `json:"last_connection_at" tab:"-"` + AutoOffThreshold xjson.Duration `json:"auto_off_threshold" tab:"-"` } // Envs gets the list of environments owned by the authenticated user diff --git a/internal/entclient/me.go b/internal/entclient/me.go index 2b44debb..2d57d8cc 100644 --- a/internal/entclient/me.go +++ b/internal/entclient/me.go @@ -6,11 +6,12 @@ import ( // User describes a Coder user account type User struct { - ID string `json:"id"` + ID string `json:"id" tab:"-"` Email string `json:"email"` Username string `json:"username"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at" tab:"-"` } // Me gets the details of the authenticated user diff --git a/internal/entclient/secrets.go b/internal/entclient/secrets.go index 98fa543b..72a62105 100644 --- a/internal/entclient/secrets.go +++ b/internal/entclient/secrets.go @@ -7,12 +7,12 @@ import ( // Secret describes a Coder secret type Secret struct { - ID string `json:"id"` + ID string `json:"id" tab:"-"` Name string `json:"name"` Value string `json:"value,omitempty"` Description string `json:"description"` CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + UpdatedAt time.Time `json:"updated_at" tab:"-"` } // Secrets gets all secrets owned by the authed user diff --git a/internal/x/xjson/duration.go b/internal/x/xjson/duration.go new file mode 100644 index 00000000..3ec08b48 --- /dev/null +++ b/internal/x/xjson/duration.go @@ -0,0 +1,33 @@ +package xjson + +import ( + "encoding/json" + "strconv" + "time" +) + +// Duration is a time.Duration that marshals to millisecond precision. +// Most javascript applications expect durations to be in milliseconds. +type Duration time.Duration + +// MarshalJSON marshals the duration to millisecond precision. +func (d Duration) MarshalJSON() ([]byte, error) { + du := time.Duration(d) + return json.Marshal(du.Milliseconds()) +} + +// UnmarshalJSON unmarshals a millisecond-precision integer to +// a time.Duration. +func (d *Duration) UnmarshalJSON(b []byte) error { + i, err := strconv.ParseInt(string(b), 10, 64) + if err != nil { + return err + } + + *d = Duration(time.Duration(i) * time.Millisecond) + return nil +} + +func (d Duration) String() string { + return time.Duration(d).String() +} diff --git a/internal/x/xtabwriter/tabwriter.go b/internal/x/xtabwriter/tabwriter.go index 1c8b1167..1345cca7 100644 --- a/internal/x/xtabwriter/tabwriter.go +++ b/internal/x/xtabwriter/tabwriter.go @@ -8,27 +8,70 @@ import ( "text/tabwriter" ) -// NewWriter chooses reasonable defaults for a human readable output of tabular data +const structFieldTagKey = "tab" + +// NewWriter chooses reasonable defaults for a human readable output of tabular data. func NewWriter() *tabwriter.Writer { return tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0) } -// StructValues tab delimits the values of a given struct +// StructValues tab delimits the values of a given struct. +// +// Tag a field `tab:"-"` to hide it from output. func StructValues(data interface{}) string { v := reflect.ValueOf(data) s := &strings.Builder{} for i := 0; i < v.NumField(); i++ { - s.WriteString(fmt.Sprintf("%s\t", v.Field(i).Interface())) + if shouldHideField(v.Type().Field(i)) { + continue + } + s.WriteString(fmt.Sprintf("%v\t", v.Field(i).Interface())) } return s.String() } -// StructFieldNames tab delimits the field names of a given struct +// StructFieldNames tab delimits the field names of a given struct. +// +// Tag a field `tab:"-"` to hide it from output. func StructFieldNames(data interface{}) string { v := reflect.ValueOf(data) s := &strings.Builder{} for i := 0; i < v.NumField(); i++ { - s.WriteString(fmt.Sprintf("%s\t", v.Type().Field(i).Name)) + field := v.Type().Field(i) + if shouldHideField(field) { + continue + } + s.WriteString(fmt.Sprintf("%s\t", field.Name)) } return s.String() } + +// WriteTable writes the given list elements to stdout in a human readable +// tabular format. Headers abide by the `tab` struct tag. +// +// `tab:"-"` omits the field and no tag defaults to the Go identifier. +func WriteTable(length int, each func(i int) interface{}) error { + if length < 1 { + return nil + } + w := NewWriter() + defer w.Flush() + for ix := 0; ix < length; ix++ { + item := each(ix) + if ix == 0 { + _, err := fmt.Fprintln(w, StructFieldNames(item)) + if err != nil { + return err + } + } + _, err := fmt.Fprintln(w, StructValues(item)) + if err != nil { + return err + } + } + return nil +} + +func shouldHideField(f reflect.StructField) bool { + return f.Tag.Get(structFieldTagKey) == "-" +} diff --git a/internal/x/xvalidate/errors.go b/internal/x/xvalidate/errors.go deleted file mode 100644 index d502850c..00000000 --- a/internal/x/xvalidate/errors.go +++ /dev/null @@ -1,101 +0,0 @@ -package xvalidate - -import ( - "bytes" - "fmt" - - "github.com/spf13/pflag" - - "go.coder.com/flog" -) - -// cerrors contains a list of errors. -type cerrors struct { - cerrors []error -} - -func (e cerrors) writeTo(buf *bytes.Buffer) { - for i, err := range e.cerrors { - if err == nil { - continue - } - buf.WriteString(err.Error()) - // don't newline after last error - if i != len(e.cerrors)-1 { - buf.WriteRune('\n') - } - } -} - -func (e cerrors) Error() string { - buf := &bytes.Buffer{} - e.writeTo(buf) - return buf.String() -} - -// stripNils removes nil errors from the slice. -func stripNils(errs []error) []error { - // We can't range since errs may be resized - // during the loop. - for i := 0; i < len(errs); i++ { - err := errs[i] - if err == nil { - // shift down - copy(errs[i:], errs[i+1:]) - // pop off last element - errs = errs[:len(errs)-1] - } - } - return errs -} - -// flatten expands all parts of cerrors onto errs. -func flatten(errs []error) []error { - nerrs := make([]error, 0, len(errs)) - for _, err := range errs { - errs, ok := err.(cerrors) - if !ok { - nerrs = append(nerrs, err) - continue - } - nerrs = append(nerrs, errs.cerrors...) - } - return nerrs -} - -// combineErrors combines multiple errors into one -func combineErrors(errs ...error) error { - errs = stripNils(errs) - switch len(errs) { - case 0: - return nil - case 1: - return errs[0] - default: - // Don't return if all of the errors of nil. - for _, err := range errs { - if err != nil { - return cerrors{cerrors: flatten(errs)} - } - } - return nil - } -} - -// Validator is a command capable of validating its flags -type Validator interface { - Validate(fl *pflag.FlagSet) []error -} - -// Validate performs validation and exits with a nonzero status code if validation fails. -// The proper errors are printed to stderr. -func Validate(fl *pflag.FlagSet, v Validator) { - errs := v.Validate(fl) - - err := combineErrors(errs...) - if err != nil { - fl.Usage() - fmt.Println("") - flog.Fatal("failed to validate this command\n%v", err) - } -}