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() -} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy