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 cf7a16b1..574a0c0b 100644 --- a/cmd/coder/auth.go +++ b/cmd/coder/auth.go @@ -3,27 +3,19 @@ package main import ( "net/url" - "go.coder.com/flog" - "cdr.dev/coder-cli/internal/config" "cdr.dev/coder-cli/internal/entclient" ) func requireAuth() *entclient.Client { sessionToken, err := config.Session.Read() - if err != nil { - flog.Fatal("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() - if err != nil { - flog.Fatal("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) - if err != nil { - flog.Fatal("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 fd59046c..cd350f84 100644 --- a/cmd/coder/ceapi.go +++ b/cmd/coder/ceapi.go @@ -27,14 +27,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) - } + requireSuccess(err, "get self: %+v", err) orgs, err := client.Orgs() - if err != nil { - flog.Fatal("get orgs: %+v", err) - } + requireSuccess(err, "get orgs: %+v", err) orgs = userOrgs(me, orgs) @@ -42,9 +38,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) - } + 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..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" @@ -43,6 +43,7 @@ func (r *rootCmd) Subcommands() []cli.Command { &versionCmd{}, &configSSHCmd{}, &usersCmd{}, + &secretsCmd{}, } } @@ -61,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 new file mode 100644 index 00000000..0fa9af52 --- /dev/null +++ b/cmd/coder/secrets.go @@ -0,0 +1,178 @@ +package main + +import ( + "fmt" + "os" + + "cdr.dev/coder-cli/internal/entclient" + "cdr.dev/coder-cli/internal/x/xtabwriter" + "cdr.dev/coder-cli/internal/x/xvalidate" + "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 = &createSecretCmd{} +) + +type secretsCmd struct { +} + +func (cmd secretsCmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "secrets", + Usage: "[subcommand]", + Desc: "interact with secrets", + } +} + +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{}, + &createSecretCmd{}, + &deleteSecretsCmd{}, + } +} + +type listSecretsCmd struct{} + +func (cmd *listSecretsCmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "ls", + Desc: "list all secrets", + } +} + +func (cmd *listSecretsCmd) Run(fl *pflag.FlagSet) { + client := requireAuth() + + secrets, err := client.Secrets() + requireSuccess(err, "failed to get secrets: %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, 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", + } +} + +func (cmd viewSecretsCmd) Run(fl *pflag.FlagSet) { + var ( + client = requireAuth() + name = fl.Arg(0) + ) + if name == "" { + exitUsage(fl) + } + + 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 { + name, value, description string +} + +func (cmd *createSecretCmd) 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 *createSecretCmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "create", + Usage: `--name MYSQL_KEY --value 123456 --description "MySQL credential for database access"`, + Desc: "insert a new secret", + } +} + +func (cmd *createSecretCmd) Run(fl *pflag.FlagSet) { + var ( + client = requireAuth() + ) + xvalidate.Validate(cmd) + + err := client.InsertSecret(entclient.InsertSecretReq{ + Name: cmd.name, + Value: cmd.value, + Description: cmd.description, + }) + requireSuccess(err, "failed to insert secret: %v", err) +} + +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") +} + +type deleteSecretsCmd struct{} + +func (cmd *deleteSecretsCmd) Spec() cli.CommandSpec { + return cli.CommandSpec{ + Name: "rm", + Usage: "[secret_name]", + Desc: "remove a secret", + } +} + +func (cmd *deleteSecretsCmd) Run(fl *pflag.FlagSet) { + var ( + client = requireAuth() + name = fl.Arg(0) + ) + if name == "" { + exitUsage(fl) + } + + err := client.DeleteSecretByName(name) + 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 bef0d7c0..5c671704 100644 --- a/cmd/coder/users.go +++ b/cmd/coder/users.go @@ -4,14 +4,12 @@ import ( "encoding/json" "fmt" "os" - "reflect" - "strings" - "text/tabwriter" + "cdr.dev/coder-cli/internal/x/xtabwriter" + "cdr.dev/coder-cli/internal/x/xvalidate" "github.com/spf13/pflag" "go.coder.com/cli" - "go.coder.com/flog" ) type usersCmd struct { @@ -39,45 +37,32 @@ 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) { + xvalidate.Validate(cmd) entClient := requireAuth() users, err := entClient.Users() - if err != nil { - flog.Fatal("failed to get users: %v", err) - } + requireSuccess(err, "failed to get users: %v", err) switch cmd.outputFmt { case "human": - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + 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, tabDelimited(u)) - if err != nil { - flog.Fatal("failed to write: %v", err) - } + _, err = fmt.Fprintln(w, xtabwriter.StructValues(u)) + requireSuccess(err, "failed to write: %v", err) } err = w.Flush() - if err != nil { - flog.Fatal("failed to flush writer: %v", err) - } + 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) - } + requireSuccess(err, "failed to encode users to json: %v", err) default: exitUsage(fl) } - } func (cmd *listCmd) RegisterFlags(fl *pflag.FlagSet) { @@ -91,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 49f58669..877085f2 100644 --- a/internal/entclient/error.go +++ b/internal/entclient/error.go @@ -1,16 +1,32 @@ package entclient import ( + "encoding/json" "net/http" "net/http/httputil" "golang.org/x/xerrors" ) +// 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 new file mode 100644 index 00000000..98fa543b --- /dev/null +++ b/internal/entclient/secrets.go @@ -0,0 +1,75 @@ +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 { + var resp interface{} + err := c.requestBody(http.MethodPost, "/api/users/me/secrets", req, &resp) + 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 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/x/xvalidate/errors.go b/internal/x/xvalidate/errors.go new file mode 100644 index 00000000..70aec071 --- /dev/null +++ b/internal/x/xvalidate/errors.go @@ -0,0 +1,96 @@ +package xvalidate + +import ( + "bytes" + + "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() []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...) + if err != nil { + flog.Fatal("failed to validate this command\n%v", err) + } +}