From 01e9b7785af9735b3a65b5bcfd979676e34014a9 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Wed, 29 Jul 2020 20:33:39 -0500 Subject: [PATCH 1/2] Add secrets ls, add, view, rm commands --- cmd/coder/auth.go | 14 +-- cmd/coder/ceapi.go | 15 ++- cmd/coder/main.go | 1 + cmd/coder/secrets.go | 167 ++++++++++++++++++++++++++++++++++ cmd/coder/users.go | 38 +++----- internal/entclient/error.go | 3 + internal/entclient/secrets.go | 74 +++++++++++++++ internal/xcli/errors.go | 105 +++++++++++++++++++++ internal/xcli/fmt.go | 34 +++++++ 9 files changed, 405 insertions(+), 46 deletions(-) create mode 100644 cmd/coder/secrets.go create mode 100644 internal/entclient/secrets.go create mode 100644 internal/xcli/errors.go create mode 100644 internal/xcli/fmt.go diff --git a/cmd/coder/auth.go b/cmd/coder/auth.go index cf7a16b1..ed697799 100644 --- a/cmd/coder/auth.go +++ b/cmd/coder/auth.go @@ -3,7 +3,7 @@ package main import ( "net/url" - "go.coder.com/flog" + "cdr.dev/coder-cli/internal/xcli" "cdr.dev/coder-cli/internal/config" "cdr.dev/coder-cli/internal/entclient" @@ -11,19 +11,13 @@ import ( func requireAuth() *entclient.Client { sessionToken, err := config.Session.Read() - if err != nil { - flog.Fatal("read session: %v (did you run coder login?)", err) - } + xcli.RequireSuccess(err, "read session: %v (did you run coder login?)", err) rawURL, err := config.URL.Read() - if err != nil { - flog.Fatal("read url: %v (did you run coder login?)", err) - } + xcli.RequireSuccess(err, "read url: %v (did you run coder login?)", err) u, err := url.Parse(rawURL) - if err != nil { - flog.Fatal("url misformatted: %v (try runing coder login)", err) - } + xcli.RequireSuccess(err, "url misformatted: %v (try runing coder login)", err) return &entclient.Client{ BaseURL: u, diff --git a/cmd/coder/ceapi.go b/cmd/coder/ceapi.go index fd59046c..4d08a1fa 100644 --- a/cmd/coder/ceapi.go +++ b/cmd/coder/ceapi.go @@ -1,6 +1,8 @@ package main import ( + "cdr.dev/coder-cli/internal/xcli" + "go.coder.com/flog" "cdr.dev/coder-cli/internal/entclient" @@ -27,14 +29,10 @@ outer: // getEnvs returns all environments for the user. func getEnvs(client *entclient.Client) []entclient.Environment { me, err := client.Me() - if err != nil { - flog.Fatal("get self: %+v", err) - } + xcli.RequireSuccess(err, "get self: %+v", err) orgs, err := client.Orgs() - if err != nil { - flog.Fatal("get orgs: %+v", err) - } + xcli.RequireSuccess(err, "get orgs: %+v", err) orgs = userOrgs(me, orgs) @@ -42,9 +40,8 @@ func getEnvs(client *entclient.Client) []entclient.Environment { for _, org := range orgs { envs, err := client.Envs(me, org) - if err != nil { - flog.Fatal("get envs for %v: %+v", org.Name, err) - } + xcli.RequireSuccess(err, "get envs for %v: %+v", org.Name, err) + for _, env := range envs { allEnvs = append(allEnvs, env) } diff --git a/cmd/coder/main.go b/cmd/coder/main.go index 93ebf022..586cd662 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -43,6 +43,7 @@ func (r *rootCmd) Subcommands() []cli.Command { &versionCmd{}, &configSSHCmd{}, &usersCmd{}, + &secretsCmd{}, } } diff --git a/cmd/coder/secrets.go b/cmd/coder/secrets.go new file mode 100644 index 00000000..c314936f --- /dev/null +++ b/cmd/coder/secrets.go @@ -0,0 +1,167 @@ +package main + +import ( + "fmt" + "os" + + "cdr.dev/coder-cli/internal/entclient" + "cdr.dev/coder-cli/internal/xcli" + "github.com/spf13/pflag" + "golang.org/x/xerrors" + + "go.coder.com/flog" + + "go.coder.com/cli" +) + +var ( + _ cli.FlaggedCommand = secretsCmd{} + _ cli.ParentCommand = secretsCmd{} + + _ cli.FlaggedCommand = &listSecretsCmd{} + _ cli.FlaggedCommand = &addSecretCmd{} +) + +type secretsCmd struct { +} + +func (cmd secretsCmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "secrets", + Desc: "interact with secrets owned by the authenticated user", + } +} + +func (cmd secretsCmd) Run(fl *pflag.FlagSet) { + exitUsage(fl) +} + +func (cmd secretsCmd) RegisterFlags(fl *pflag.FlagSet) {} + +func (cmd secretsCmd) Subcommands() []cli.Command { + return []cli.Command{ + &listSecretsCmd{}, + &viewSecretsCmd{}, + &addSecretCmd{}, + &deleteSecretsCmd{}, + } +} + +type listSecretsCmd struct{} + +func (cmd listSecretsCmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "ls", + Desc: "list all secrets owned by the authenticated user", + } +} + +func (cmd listSecretsCmd) Run(fl *pflag.FlagSet) { + client := requireAuth() + + secrets, err := client.Secrets() + xcli.RequireSuccess(err, "failed to get secrets: %v", err) + + w := xcli.HumanReadableWriter() + if len(secrets) > 0 { + _, err := fmt.Fprintln(w, xcli.TabDelimitedStructHeaders(secrets[0])) + xcli.RequireSuccess(err, "failed to write: %v", err) + } + for _, s := range secrets { + s.Value = "******" // value is omitted from bulk responses + + _, err = fmt.Fprintln(w, xcli.TabDelimitedStructValues(s)) + xcli.RequireSuccess(err, "failed to write: %v", err) + } + err = w.Flush() + xcli.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 owned by the authenticated user", + } +} + +func (cmd viewSecretsCmd) Run(fl *pflag.FlagSet) { + var ( + client = requireAuth() + name = fl.Arg(0) + ) + + secret, err := client.SecretByName(name) + xcli.RequireSuccess(err, "failed to get secret by name: %v", err) + + _, err = fmt.Fprintln(os.Stdout, secret.Value) + xcli.RequireSuccess(err, "failed to write: %v", err) +} + +type addSecretCmd struct { + name, value, description string +} + +func (cmd *addSecretCmd) Validate() (e []error) { + if cmd.name == "" { + e = append(e, xerrors.New("--name is a required flag")) + } + if cmd.value == "" { + e = append(e, xerrors.New("--value is a required flag")) + } + return e +} + +func (cmd *addSecretCmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "add", + Usage: `--name MYSQL_KEY --value 123456 --description "MySQL credential for database access"`, + Desc: "insert a new secret", + } +} + +func (cmd *addSecretCmd) Run(fl *pflag.FlagSet) { + var ( + client = requireAuth() + ) + xcli.Validate(cmd) + + err := client.InsertSecret(entclient.InsertSecretReq{ + Name: cmd.name, + Value: cmd.value, + Description: cmd.description, + }) + xcli.RequireSuccess(err, "failed to insert secret: %v", err) +} + +func (cmd *addSecretCmd) RegisterFlags(fl *pflag.FlagSet) { + fl.StringVar(&cmd.name, "name", "", "the name of the secret") + fl.StringVar(&cmd.value, "value", "", "the value of the secret") + fl.StringVar(&cmd.description, "description", "", "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 by name", + } +} + +func (cmd *deleteSecretsCmd) Run(fl *pflag.FlagSet) { + var ( + client = requireAuth() + name = fl.Arg(0) + ) + + err := client.DeleteSecretByName(name) + xcli.RequireSuccess(err, "failed to delete secret: %v", err) + + flog.Info("Successfully deleted secret %q", name) +} diff --git a/cmd/coder/users.go b/cmd/coder/users.go index bef0d7c0..25fbaca6 100644 --- a/cmd/coder/users.go +++ b/cmd/coder/users.go @@ -4,14 +4,11 @@ import ( "encoding/json" "fmt" "os" - "reflect" - "strings" - "text/tabwriter" + "cdr.dev/coder-cli/internal/xcli" "github.com/spf13/pflag" "go.coder.com/cli" - "go.coder.com/flog" ) type usersCmd struct { @@ -39,41 +36,28 @@ type listCmd struct { outputFmt string } -func tabDelimited(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())) - } - return s.String() -} - func (cmd *listCmd) Run(fl *pflag.FlagSet) { entClient := requireAuth() users, err := entClient.Users() - if err != nil { - flog.Fatal("failed to get users: %v", err) - } + xcli.RequireSuccess(err, "failed to get users: %v", err) switch cmd.outputFmt { case "human": - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + w := xcli.HumanReadableWriter() + if len(users) > 0 { + _, err = fmt.Fprintln(w, xcli.TabDelimitedStructHeaders(users[0])) + xcli.RequireSuccess(err, "failed to write: %v", err) + } for _, u := range users { - _, err = fmt.Fprintln(w, tabDelimited(u)) - if err != nil { - flog.Fatal("failed to write: %v", err) - } + _, err = fmt.Fprintln(w, xcli.TabDelimitedStructValues(u)) + xcli.RequireSuccess(err, "failed to write: %v", err) } err = w.Flush() - if err != nil { - flog.Fatal("failed to flush writer: %v", err) - } + xcli.RequireSuccess(err, "failed to flush writer: %v", err) case "json": err = json.NewEncoder(os.Stdout).Encode(users) - if err != nil { - flog.Fatal("failed to encode users to json: %v", err) - } + xcli.RequireSuccess(err, "failed to encode users to json: %v", err) default: exitUsage(fl) } diff --git a/internal/entclient/error.go b/internal/entclient/error.go index 49f58669..62e6b405 100644 --- a/internal/entclient/error.go +++ b/internal/entclient/error.go @@ -7,6 +7,9 @@ import ( "golang.org/x/xerrors" ) +// ErrNotFound describes an error case in which the request resource could not be found +var ErrNotFound = xerrors.Errorf("resource not found") + func bodyError(resp *http.Response) error { byt, err := httputil.DumpResponse(resp, false) if err != nil { diff --git a/internal/entclient/secrets.go b/internal/entclient/secrets.go new file mode 100644 index 00000000..04d609ae --- /dev/null +++ b/internal/entclient/secrets.go @@ -0,0 +1,74 @@ +package entclient + +import ( + "net/http" + "time" +) + +// Secret describes a Coder secret +type Secret struct { + ID string `json:"id"` + 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"` +} + +// Secrets gets all secrets owned by the authed user +func (c *Client) Secrets() ([]Secret, error) { + var secrets []Secret + err := c.requestBody(http.MethodGet, "/api/users/me/secrets", nil, &secrets) + return secrets, err +} + +func (c *Client) secretByID(id string) (*Secret, error) { + var secret Secret + err := c.requestBody(http.MethodGet, "/api/users/me/secrets/"+id, nil, &secret) + return &secret, err +} + +func (c *Client) secretNameToID(name string) (id string, _ error) { + secrets, err := c.Secrets() + if err != nil { + return "", err + } + for _, s := range secrets { + if s.Name == name { + return s.ID, nil + } + } + return "", ErrNotFound +} + +// SecretByName gets a secret object by name +func (c *Client) SecretByName(name string) (*Secret, error) { + id, err := c.secretNameToID(name) + if err != nil { + return nil, err + } + return c.secretByID(id) +} + +// InsertSecretReq describes the request body for creating a new secret +type InsertSecretReq struct { + Name string `json:"name"` + Value string `json:"value"` + Description string `json:"description"` +} + +// InsertSecret adds a new secret for the authed user +func (c *Client) InsertSecret(req InsertSecretReq) error { + _, err := c.request(http.MethodPost, "/api/users/me/secrets", req) + return err +} + +// DeleteSecretByName deletes the authenticated users secret with the given name +func (c *Client) DeleteSecretByName(name string) error { + id, err := c.secretNameToID(name) + if err != nil { + return nil + } + _, err = c.request(http.MethodDelete, "/api/users/me/secrets/"+id, nil) + return err +} diff --git a/internal/xcli/errors.go b/internal/xcli/errors.go new file mode 100644 index 00000000..771572ff --- /dev/null +++ b/internal/xcli/errors.go @@ -0,0 +1,105 @@ +package xcli + +import ( + "bytes" + + "go.coder.com/flog" +) + +// RequireSuccess prints the given message and format args as a fatal error if err != nil +func RequireSuccess(err error, msg string, args ...interface{}) { + if err != nil { + flog.Fatal(msg, args...) + } +} + +// cerrors contains a list of errors. +// New cerrors should be created via Combine. +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 +} + +// Combine combines multiple errors into one. +// If no errors are provided, nil is returned. +// If a single error is provided, it is returned. +// Otherwise, an cerrors is returned. +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() []error +} + +// Validate performs validation and exits with a nonzero status code if validation fails. +// The proper errors are printed to stderr. +func Validate(v Validator) { + errs := v.Validate() + + err := combineErrors(errs...) + RequireSuccess(err, "failed to validate this command\n%v", err) +} diff --git a/internal/xcli/fmt.go b/internal/xcli/fmt.go new file mode 100644 index 00000000..6ca9e477 --- /dev/null +++ b/internal/xcli/fmt.go @@ -0,0 +1,34 @@ +package xcli + +import ( + "fmt" + "os" + "reflect" + "strings" + "text/tabwriter" +) + +// HumanReadableWriter chooses reasonable defaults for a human readable output of tabular data +func HumanReadableWriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) +} + +// TabDelimitedStructValues tab delimits the values of a given struct +func TabDelimitedStructValues(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())) + } + return s.String() +} + +// TabDelimitedStructHeaders tab delimits the field names of a given struct +func TabDelimitedStructHeaders(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)) + } + return s.String() +} From cbdd4b638c392c938f986b34c221012003fdf273 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Wed, 29 Jul 2020 20:54:09 -0500 Subject: [PATCH 2/2] Add integration tests for Secrets commands --- ci/integration/integration_test.go | 124 +++++++++++------- ci/integration/setup_test.go | 60 +++++++++ ci/tcli/tcli.go | 5 +- cmd/coder/auth.go | 8 +- cmd/coder/ceapi.go | 8 +- cmd/coder/main.go | 9 +- cmd/coder/secrets.go | 69 ++++++---- cmd/coder/shell.go | 2 +- cmd/coder/users.go | 28 ++-- internal/entclient/error.go | 17 ++- internal/entclient/request.go | 2 +- internal/entclient/secrets.go | 5 +- internal/x/xtabwriter/tabwriter.go | 34 +++++ internal/{ => x}/xterminal/doc.go | 0 internal/{ => x}/xterminal/terminal.go | 0 .../{ => x}/xterminal/terminal_windows.go | 0 internal/{xcli => x/xvalidate}/errors.go | 19 +-- internal/xcli/fmt.go | 34 ----- 18 files changed, 269 insertions(+), 155 deletions(-) create mode 100644 ci/integration/setup_test.go create mode 100644 internal/x/xtabwriter/tabwriter.go rename internal/{ => x}/xterminal/doc.go (100%) rename internal/{ => x}/xterminal/terminal.go (100%) rename internal/{ => x}/xterminal/terminal_windows.go (100%) rename internal/{xcli => x/xvalidate}/errors.go (78%) delete mode 100644 internal/xcli/fmt.go diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index a452cd18..26f510f9 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -4,10 +4,8 @@ import ( "context" "encoding/json" "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + "math/rand" + "regexp" "testing" "time" @@ -17,50 +15,6 @@ import ( "cdr.dev/slog/sloggers/slogtest/assert" ) -func build(path string) error { - cmd := exec.Command( - "sh", "-c", - fmt.Sprintf("cd ../../ && go build -o %s ./cmd/coder", path), - ) - cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0") - - _, err := cmd.CombinedOutput() - if err != nil { - return err - } - return nil -} - -var binpath string - -func init() { - cwd, err := os.Getwd() - if err != nil { - panic(err) - } - - binpath = filepath.Join(cwd, "bin", "coder") - err = build(binpath) - if err != nil { - panic(err) - } -} - -// write session tokens to the given container runner -func headlessLogin(ctx context.Context, t *testing.T, runner *tcli.ContainerRunner) { - creds := login(ctx, t) - cmd := exec.CommandContext(ctx, "sh", "-c", "mkdir -p ~/.config/coder && cat > ~/.config/coder/session") - - // !IMPORTANT: be careful that this does not appear in logs - cmd.Stdin = strings.NewReader(creds.token) - runner.RunCmd(cmd).Assert(t, - tcli.Success(), - ) - runner.Run(ctx, fmt.Sprintf("echo -ne %s > ~/.config/coder/url", creds.url)).Assert(t, - tcli.Success(), - ) -} - func TestCoderCLI(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) @@ -116,7 +70,7 @@ func TestCoderCLI(t *testing.T) { var user entclient.User c.Run(ctx, `coder users ls -o json | jq -c '.[] | select( .username == "charlie")'`).Assert(t, tcli.Success(), - jsonUnmarshals(&user), + 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) @@ -135,10 +89,80 @@ func TestCoderCLI(t *testing.T) { ) } -func jsonUnmarshals(target interface{}) tcli.Assertion { +func TestSecrets(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: "secrets-cli-tests", + BindMounts: map[string]string{ + binpath: "/bin/coder", + }, + }) + assert.Success(t, "new run container", err) + defer c.Close() + + headlessLogin(ctx, t, c) + + c.Run(ctx, "coder secrets ls").Assert(t, + tcli.Success(), + ) + + name, value := randString(8), randString(8) + + c.Run(ctx, "coder secrets create").Assert(t, + tcli.Error(), + tcli.StdoutEmpty(), + tcli.StderrMatches("required flag"), + ) + + c.Run(ctx, fmt.Sprintf("coder secrets create --name %s --value %s", name, value)).Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + ) + + c.Run(ctx, "coder secrets ls").Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + tcli.StdoutMatches("Value"), + tcli.StdoutMatches(regexp.QuoteMeta(name)), + ) + + c.Run(ctx, "coder secrets view "+name).Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + tcli.StdoutMatches(regexp.QuoteMeta(value)), + ) + + c.Run(ctx, "coder secrets rm").Assert(t, + tcli.Error(), + ) + c.Run(ctx, "coder secrets rm "+name).Assert(t, + tcli.Success(), + ) + c.Run(ctx, "coder secrets view "+name).Assert(t, + tcli.Error(), + tcli.StdoutEmpty(), + ) +} + +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 { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[seededRand.Intn(len(charset))] + } + return string(b) +} diff --git a/ci/integration/setup_test.go b/ci/integration/setup_test.go new file mode 100644 index 00000000..9ae69c29 --- /dev/null +++ b/ci/integration/setup_test.go @@ -0,0 +1,60 @@ +package integration + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "cdr.dev/coder-cli/ci/tcli" + "golang.org/x/xerrors" +) + +var binpath string + +// initialize integration tests by building the coder-cli binary +func init() { + cwd, err := os.Getwd() + if err != nil { + panic(err) + } + + binpath = filepath.Join(cwd, "bin", "coder") + err = build(binpath) + if err != nil { + panic(err) + } +} + +// build the coder-cli binary and move to the integration testing bin directory +func build(path string) error { + cmd := exec.Command( + "sh", "-c", + fmt.Sprintf("cd ../../ && go build -o %s ./cmd/coder", path), + ) + cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0") + + out, err := cmd.CombinedOutput() + if err != nil { + return xerrors.Errorf("failed to build coder-cli (%v): %w", string(out), err) + } + return nil +} + +// write session tokens to the given container runner +func headlessLogin(ctx context.Context, t *testing.T, runner *tcli.ContainerRunner) { + creds := login(ctx, t) + cmd := exec.CommandContext(ctx, "sh", "-c", "mkdir -p ~/.config/coder && cat > ~/.config/coder/session") + + // !IMPORTANT: be careful that this does not appear in logs + cmd.Stdin = strings.NewReader(creds.token) + runner.RunCmd(cmd).Assert(t, + tcli.Success(), + ) + runner.Run(ctx, fmt.Sprintf("echo -ne %s > ~/.config/coder/url", creds.url)).Assert(t, + tcli.Success(), + ) +} diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index 101cc926..8a9fd2be 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -163,13 +163,16 @@ type Assertable struct { } // Assert runs the Assertable and -func (a Assertable) Assert(t *testing.T, option ...Assertion) { +func (a *Assertable) Assert(t *testing.T, option ...Assertion) { slog.Helper() var ( stdout bytes.Buffer stderr bytes.Buffer result CommandResult ) + if a.cmd == nil { + slogtest.Fatal(t, "test failed to initialize: no command specified") + } a.cmd.Stdout = &stdout a.cmd.Stderr = &stderr diff --git a/cmd/coder/auth.go b/cmd/coder/auth.go index ed697799..574a0c0b 100644 --- a/cmd/coder/auth.go +++ b/cmd/coder/auth.go @@ -3,21 +3,19 @@ package main import ( "net/url" - "cdr.dev/coder-cli/internal/xcli" - "cdr.dev/coder-cli/internal/config" "cdr.dev/coder-cli/internal/entclient" ) func requireAuth() *entclient.Client { sessionToken, err := config.Session.Read() - xcli.RequireSuccess(err, "read session: %v (did you run coder login?)", err) + requireSuccess(err, "read session: %v (did you run coder login?)", err) rawURL, err := config.URL.Read() - xcli.RequireSuccess(err, "read url: %v (did you run coder login?)", err) + requireSuccess(err, "read url: %v (did you run coder login?)", err) u, err := url.Parse(rawURL) - xcli.RequireSuccess(err, "url misformatted: %v (try runing coder login)", err) + requireSuccess(err, "url misformatted: %v (try runing coder login)", err) return &entclient.Client{ BaseURL: u, diff --git a/cmd/coder/ceapi.go b/cmd/coder/ceapi.go index 4d08a1fa..cd350f84 100644 --- a/cmd/coder/ceapi.go +++ b/cmd/coder/ceapi.go @@ -1,8 +1,6 @@ package main import ( - "cdr.dev/coder-cli/internal/xcli" - "go.coder.com/flog" "cdr.dev/coder-cli/internal/entclient" @@ -29,10 +27,10 @@ outer: // getEnvs returns all environments for the user. func getEnvs(client *entclient.Client) []entclient.Environment { me, err := client.Me() - xcli.RequireSuccess(err, "get self: %+v", err) + requireSuccess(err, "get self: %+v", err) orgs, err := client.Orgs() - xcli.RequireSuccess(err, "get orgs: %+v", err) + requireSuccess(err, "get orgs: %+v", err) orgs = userOrgs(me, orgs) @@ -40,7 +38,7 @@ func getEnvs(client *entclient.Client) []entclient.Environment { for _, org := range orgs { envs, err := client.Envs(me, org) - xcli.RequireSuccess(err, "get envs for %v: %+v", org.Name, err) + requireSuccess(err, "get envs for %v: %+v", org.Name, err) for _, env := range envs { allEnvs = append(allEnvs, env) diff --git a/cmd/coder/main.go b/cmd/coder/main.go index 586cd662..5680d30d 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -6,7 +6,7 @@ import ( _ "net/http/pprof" "os" - "cdr.dev/coder-cli/internal/xterminal" + "cdr.dev/coder-cli/internal/x/xterminal" "github.com/spf13/pflag" "go.coder.com/flog" @@ -62,3 +62,10 @@ func main() { cli.RunRoot(&rootCmd{}) } + +// requireSuccess prints the given message and format args as a fatal error if err != nil +func requireSuccess(err error, msg string, args ...interface{}) { + if err != nil { + flog.Fatal(msg, args...) + } +} diff --git a/cmd/coder/secrets.go b/cmd/coder/secrets.go index c314936f..0fa9af52 100644 --- a/cmd/coder/secrets.go +++ b/cmd/coder/secrets.go @@ -5,7 +5,8 @@ import ( "os" "cdr.dev/coder-cli/internal/entclient" - "cdr.dev/coder-cli/internal/xcli" + "cdr.dev/coder-cli/internal/x/xtabwriter" + "cdr.dev/coder-cli/internal/x/xvalidate" "github.com/spf13/pflag" "golang.org/x/xerrors" @@ -19,7 +20,7 @@ var ( _ cli.ParentCommand = secretsCmd{} _ cli.FlaggedCommand = &listSecretsCmd{} - _ cli.FlaggedCommand = &addSecretCmd{} + _ cli.FlaggedCommand = &createSecretCmd{} ) type secretsCmd struct { @@ -27,8 +28,9 @@ type secretsCmd struct { func (cmd secretsCmd) Spec() cli.CommandSpec { return cli.CommandSpec{ - Name: "secrets", - Desc: "interact with secrets owned by the authenticated user", + Name: "secrets", + Usage: "[subcommand]", + Desc: "interact with secrets", } } @@ -42,39 +44,42 @@ func (cmd secretsCmd) Subcommands() []cli.Command { return []cli.Command{ &listSecretsCmd{}, &viewSecretsCmd{}, - &addSecretCmd{}, + &createSecretCmd{}, &deleteSecretsCmd{}, } } type listSecretsCmd struct{} -func (cmd listSecretsCmd) Spec() cli.CommandSpec { +func (cmd *listSecretsCmd) Spec() cli.CommandSpec { return cli.CommandSpec{ Name: "ls", - Desc: "list all secrets owned by the authenticated user", + Desc: "list all secrets", } } -func (cmd listSecretsCmd) Run(fl *pflag.FlagSet) { +func (cmd *listSecretsCmd) Run(fl *pflag.FlagSet) { client := requireAuth() secrets, err := client.Secrets() - xcli.RequireSuccess(err, "failed to get secrets: %v", err) + requireSuccess(err, "failed to get secrets: %v", err) - w := xcli.HumanReadableWriter() - if len(secrets) > 0 { - _, err := fmt.Fprintln(w, xcli.TabDelimitedStructHeaders(secrets[0])) - xcli.RequireSuccess(err, "failed to write: %v", err) + if len(secrets) < 1 { + flog.Info("No secrets found") + return } + + w := xtabwriter.NewWriter() + _, err = fmt.Fprintln(w, xtabwriter.StructFieldNames(secrets[0])) + requireSuccess(err, "failed to write: %v", err) for _, s := range secrets { s.Value = "******" // value is omitted from bulk responses - _, err = fmt.Fprintln(w, xcli.TabDelimitedStructValues(s)) - xcli.RequireSuccess(err, "failed to write: %v", err) + _, err = fmt.Fprintln(w, xtabwriter.StructValues(s)) + requireSuccess(err, "failed to write: %v", err) } err = w.Flush() - xcli.RequireSuccess(err, "failed to flush writer: %v", err) + requireSuccess(err, "failed to flush writer: %v", err) } func (cmd *listSecretsCmd) RegisterFlags(fl *pflag.FlagSet) {} @@ -85,7 +90,7 @@ func (cmd viewSecretsCmd) Spec() cli.CommandSpec { return cli.CommandSpec{ Name: "view", Usage: "[secret_name]", - Desc: "view a secret owned by the authenticated user", + Desc: "view a secret", } } @@ -94,19 +99,22 @@ func (cmd viewSecretsCmd) Run(fl *pflag.FlagSet) { client = requireAuth() name = fl.Arg(0) ) + if name == "" { + exitUsage(fl) + } secret, err := client.SecretByName(name) - xcli.RequireSuccess(err, "failed to get secret by name: %v", err) + requireSuccess(err, "failed to get secret by name: %v", err) _, err = fmt.Fprintln(os.Stdout, secret.Value) - xcli.RequireSuccess(err, "failed to write: %v", err) + requireSuccess(err, "failed to write: %v", err) } -type addSecretCmd struct { +type createSecretCmd struct { name, value, description string } -func (cmd *addSecretCmd) Validate() (e []error) { +func (cmd *createSecretCmd) Validate() (e []error) { if cmd.name == "" { e = append(e, xerrors.New("--name is a required flag")) } @@ -116,29 +124,29 @@ func (cmd *addSecretCmd) Validate() (e []error) { return e } -func (cmd *addSecretCmd) Spec() cli.CommandSpec { +func (cmd *createSecretCmd) Spec() cli.CommandSpec { return cli.CommandSpec{ - Name: "add", + Name: "create", Usage: `--name MYSQL_KEY --value 123456 --description "MySQL credential for database access"`, Desc: "insert a new secret", } } -func (cmd *addSecretCmd) Run(fl *pflag.FlagSet) { +func (cmd *createSecretCmd) Run(fl *pflag.FlagSet) { var ( client = requireAuth() ) - xcli.Validate(cmd) + xvalidate.Validate(cmd) err := client.InsertSecret(entclient.InsertSecretReq{ Name: cmd.name, Value: cmd.value, Description: cmd.description, }) - xcli.RequireSuccess(err, "failed to insert secret: %v", err) + requireSuccess(err, "failed to insert secret: %v", err) } -func (cmd *addSecretCmd) RegisterFlags(fl *pflag.FlagSet) { +func (cmd *createSecretCmd) RegisterFlags(fl *pflag.FlagSet) { fl.StringVar(&cmd.name, "name", "", "the name of the secret") fl.StringVar(&cmd.value, "value", "", "the value of the secret") fl.StringVar(&cmd.description, "description", "", "a description of the secret") @@ -150,7 +158,7 @@ func (cmd *deleteSecretsCmd) Spec() cli.CommandSpec { return cli.CommandSpec{ Name: "rm", Usage: "[secret_name]", - Desc: "remove a secret by name", + Desc: "remove a secret", } } @@ -159,9 +167,12 @@ func (cmd *deleteSecretsCmd) Run(fl *pflag.FlagSet) { client = requireAuth() name = fl.Arg(0) ) + if name == "" { + exitUsage(fl) + } err := client.DeleteSecretByName(name) - xcli.RequireSuccess(err, "failed to delete secret: %v", err) + requireSuccess(err, "failed to delete secret: %v", err) flog.Info("Successfully deleted secret %q", name) } diff --git a/cmd/coder/shell.go b/cmd/coder/shell.go index 7e8b70ef..c7b42564 100644 --- a/cmd/coder/shell.go +++ b/cmd/coder/shell.go @@ -17,7 +17,7 @@ import ( "go.coder.com/flog" "cdr.dev/coder-cli/internal/activity" - "cdr.dev/coder-cli/internal/xterminal" + "cdr.dev/coder-cli/internal/x/xterminal" "cdr.dev/wsep" ) diff --git a/cmd/coder/users.go b/cmd/coder/users.go index 25fbaca6..5c671704 100644 --- a/cmd/coder/users.go +++ b/cmd/coder/users.go @@ -5,7 +5,8 @@ import ( "fmt" "os" - "cdr.dev/coder-cli/internal/xcli" + "cdr.dev/coder-cli/internal/x/xtabwriter" + "cdr.dev/coder-cli/internal/x/xvalidate" "github.com/spf13/pflag" "go.coder.com/cli" @@ -37,31 +38,31 @@ type listCmd struct { } func (cmd *listCmd) Run(fl *pflag.FlagSet) { + xvalidate.Validate(cmd) entClient := requireAuth() users, err := entClient.Users() - xcli.RequireSuccess(err, "failed to get users: %v", err) + requireSuccess(err, "failed to get users: %v", err) switch cmd.outputFmt { case "human": - w := xcli.HumanReadableWriter() + w := xtabwriter.NewWriter() if len(users) > 0 { - _, err = fmt.Fprintln(w, xcli.TabDelimitedStructHeaders(users[0])) - xcli.RequireSuccess(err, "failed to write: %v", err) + _, err = fmt.Fprintln(w, xtabwriter.StructFieldNames(users[0])) + requireSuccess(err, "failed to write: %v", err) } for _, u := range users { - _, err = fmt.Fprintln(w, xcli.TabDelimitedStructValues(u)) - xcli.RequireSuccess(err, "failed to write: %v", err) + _, err = fmt.Fprintln(w, xtabwriter.StructValues(u)) + requireSuccess(err, "failed to write: %v", err) } err = w.Flush() - xcli.RequireSuccess(err, "failed to flush writer: %v", err) + requireSuccess(err, "failed to flush writer: %v", err) case "json": err = json.NewEncoder(os.Stdout).Encode(users) - xcli.RequireSuccess(err, "failed to encode users to json: %v", err) + requireSuccess(err, "failed to encode users to json: %v", err) default: exitUsage(fl) } - } func (cmd *listCmd) RegisterFlags(fl *pflag.FlagSet) { @@ -75,3 +76,10 @@ func (cmd *listCmd) Spec() cli.CommandSpec { Desc: "list all users", } } + +func (cmd *listCmd) Validate() (e []error) { + if !(cmd.outputFmt == "json" || cmd.outputFmt == "human") { + e = append(e, fmt.Errorf(`--output must be "json" or "human"`)) + } + return e +} diff --git a/internal/entclient/error.go b/internal/entclient/error.go index 62e6b405..877085f2 100644 --- a/internal/entclient/error.go +++ b/internal/entclient/error.go @@ -1,19 +1,32 @@ package entclient import ( + "encoding/json" "net/http" "net/http/httputil" "golang.org/x/xerrors" ) -// ErrNotFound describes an error case in which the request resource could not be found +// ErrNotFound describes an error case in which the requested resource could not be found var ErrNotFound = xerrors.Errorf("resource not found") +type apiError struct { + Err struct { + Msg string `json:"msg,required"` + } `json:"error"` +} + func bodyError(resp *http.Response) error { byt, err := httputil.DumpResponse(resp, false) if err != nil { return xerrors.Errorf("dump response: %w", err) } - return xerrors.Errorf("%s\n%s", resp.Request.URL, byt) + + var msg apiError + err = json.NewDecoder(resp.Body).Decode(&msg) + if err != nil || msg.Err.Msg == "" { + return xerrors.Errorf("%s\n%s", resp.Request.URL, byt) + } + return xerrors.Errorf("%s\n%s%s", resp.Request.URL, byt, msg.Err.Msg) } diff --git a/internal/entclient/request.go b/internal/entclient/request.go index b5873f81..dfd0d6fe 100644 --- a/internal/entclient/request.go +++ b/internal/entclient/request.go @@ -39,7 +39,7 @@ func (c Client) requestBody( } defer resp.Body.Close() - if resp.StatusCode != 200 { + if resp.StatusCode > 299 { return bodyError(resp) } diff --git a/internal/entclient/secrets.go b/internal/entclient/secrets.go index 04d609ae..98fa543b 100644 --- a/internal/entclient/secrets.go +++ b/internal/entclient/secrets.go @@ -59,7 +59,8 @@ type InsertSecretReq struct { // InsertSecret adds a new secret for the authed user func (c *Client) InsertSecret(req InsertSecretReq) error { - _, err := c.request(http.MethodPost, "/api/users/me/secrets", req) + var resp interface{} + err := c.requestBody(http.MethodPost, "/api/users/me/secrets", req, &resp) return err } @@ -67,7 +68,7 @@ func (c *Client) InsertSecret(req InsertSecretReq) error { func (c *Client) DeleteSecretByName(name string) error { id, err := c.secretNameToID(name) if err != nil { - return nil + return err } _, err = c.request(http.MethodDelete, "/api/users/me/secrets/"+id, nil) return err diff --git a/internal/x/xtabwriter/tabwriter.go b/internal/x/xtabwriter/tabwriter.go new file mode 100644 index 00000000..1c8b1167 --- /dev/null +++ b/internal/x/xtabwriter/tabwriter.go @@ -0,0 +1,34 @@ +package xtabwriter + +import ( + "fmt" + "os" + "reflect" + "strings" + "text/tabwriter" +) + +// 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 +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())) + } + return s.String() +} + +// StructFieldNames tab delimits the field names of a given struct +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)) + } + return s.String() +} diff --git a/internal/xterminal/doc.go b/internal/x/xterminal/doc.go similarity index 100% rename from internal/xterminal/doc.go rename to internal/x/xterminal/doc.go diff --git a/internal/xterminal/terminal.go b/internal/x/xterminal/terminal.go similarity index 100% rename from internal/xterminal/terminal.go rename to internal/x/xterminal/terminal.go diff --git a/internal/xterminal/terminal_windows.go b/internal/x/xterminal/terminal_windows.go similarity index 100% rename from internal/xterminal/terminal_windows.go rename to internal/x/xterminal/terminal_windows.go diff --git a/internal/xcli/errors.go b/internal/x/xvalidate/errors.go similarity index 78% rename from internal/xcli/errors.go rename to internal/x/xvalidate/errors.go index 771572ff..70aec071 100644 --- a/internal/xcli/errors.go +++ b/internal/x/xvalidate/errors.go @@ -1,4 +1,4 @@ -package xcli +package xvalidate import ( "bytes" @@ -6,15 +6,7 @@ import ( "go.coder.com/flog" ) -// RequireSuccess prints the given message and format args as a fatal error if err != nil -func RequireSuccess(err error, msg string, args ...interface{}) { - if err != nil { - flog.Fatal(msg, args...) - } -} - // cerrors contains a list of errors. -// New cerrors should be created via Combine. type cerrors struct { cerrors []error } @@ -68,10 +60,7 @@ func flatten(errs []error) []error { return nerrs } -// Combine combines multiple errors into one. -// If no errors are provided, nil is returned. -// If a single error is provided, it is returned. -// Otherwise, an cerrors is returned. +// combineErrors combines multiple errors into one func combineErrors(errs ...error) error { errs = stripNils(errs) switch len(errs) { @@ -101,5 +90,7 @@ func Validate(v Validator) { errs := v.Validate() err := combineErrors(errs...) - RequireSuccess(err, "failed to validate this command\n%v", err) + if err != nil { + flog.Fatal("failed to validate this command\n%v", err) + } } diff --git a/internal/xcli/fmt.go b/internal/xcli/fmt.go deleted file mode 100644 index 6ca9e477..00000000 --- a/internal/xcli/fmt.go +++ /dev/null @@ -1,34 +0,0 @@ -package xcli - -import ( - "fmt" - "os" - "reflect" - "strings" - "text/tabwriter" -) - -// HumanReadableWriter chooses reasonable defaults for a human readable output of tabular data -func HumanReadableWriter() *tabwriter.Writer { - return tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) -} - -// TabDelimitedStructValues tab delimits the values of a given struct -func TabDelimitedStructValues(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())) - } - return s.String() -} - -// TabDelimitedStructHeaders tab delimits the field names of a given struct -func TabDelimitedStructHeaders(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)) - } - return s.String() -}