From b5487cbee2e6b115a24fb6f77cf3dc6906fbd3d7 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Thu, 30 Jul 2020 15:08:29 -0500 Subject: [PATCH] Add more secret creation input paths * --from-literal * --from-file * fallback to stdin prompt --- ci/integration/integration_test.go | 63 +------------------ ci/integration/secrets_test.go | 99 ++++++++++++++++++++++++++++++ cmd/coder/secrets.go | 76 +++++++++++++++++------ cmd/coder/users.go | 4 +- go.mod | 1 + go.sum | 13 ++++ internal/x/xvalidate/errors.go | 11 +++- 7 files changed, 180 insertions(+), 87 deletions(-) create mode 100644 ci/integration/secrets_test.go diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 26f510f9..dc921f29 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -3,9 +3,7 @@ package integration import ( "context" "encoding/json" - "fmt" "math/rand" - "regexp" "testing" "time" @@ -89,65 +87,6 @@ func TestCoderCLI(t *testing.T) { ) } -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() @@ -159,7 +98,7 @@ func stdoutUnmarshalsJSON(target interface{}) tcli.Assertion { var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) func randString(length int) string { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" b := make([]byte, length) for i := range b { b[i] = charset[seededRand.Intn(len(charset))] diff --git a/ci/integration/secrets_test.go b/ci/integration/secrets_test.go new file mode 100644 index 00000000..b77c345b --- /dev/null +++ b/ci/integration/secrets_test.go @@ -0,0 +1,99 @@ +package integration + +import ( + "context" + "fmt" + "regexp" + "testing" + "time" + + "cdr.dev/coder-cli/ci/tcli" + "cdr.dev/slog/sloggers/slogtest/assert" +) + +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(), + ) + + // this tests the "Value:" prompt fallback + c.Run(ctx, fmt.Sprintf("echo %s | coder secrets create %s --from-prompt", value, name)).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(), + ) + + name, value = randString(8), randString(8) + + c.Run(ctx, fmt.Sprintf("coder secrets create %s --from-literal %s", name, value)).Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + ) + + c.Run(ctx, "coder secrets view "+name).Assert(t, + tcli.Success(), + tcli.StdoutMatches(regexp.QuoteMeta(value)), + ) + + name, value = randString(8), randString(8) + c.Run(ctx, fmt.Sprintf("echo %s > ~/secret.json", value)).Assert(t, + tcli.Success(), + ) + c.Run(ctx, fmt.Sprintf("coder secrets create %s --from-literal %s --from-file ~/secret.json", name, value)).Assert(t, + tcli.Error(), + ) + c.Run(ctx, fmt.Sprintf("coder secrets create %s --from-file ~/secret.json", name)).Assert(t, + tcli.Success(), + ) + // + c.Run(ctx, "coder secrets view "+name).Assert(t, + tcli.Success(), + tcli.StdoutMatches(regexp.QuoteMeta(value)), + ) +} diff --git a/cmd/coder/secrets.go b/cmd/coder/secrets.go index 0fa9af52..a07df2de 100644 --- a/cmd/coder/secrets.go +++ b/cmd/coder/secrets.go @@ -2,11 +2,13 @@ package main import ( "fmt" + "io/ioutil" "os" "cdr.dev/coder-cli/internal/entclient" "cdr.dev/coder-cli/internal/x/xtabwriter" "cdr.dev/coder-cli/internal/x/xvalidate" + "github.com/manifoldco/promptui" "github.com/spf13/pflag" "golang.org/x/xerrors" @@ -111,45 +113,79 @@ func (cmd viewSecretsCmd) Run(fl *pflag.FlagSet) { } 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 + description string + fromFile string + fromLiteral string + fromPrompt bool } 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", + Usage: `[secret_name] [...flags]`, + Desc: "create a new secret", + } +} + +func (cmd *createSecretCmd) Validate(fl *pflag.FlagSet) (e []error) { + if cmd.fromPrompt && (cmd.fromLiteral != "" || cmd.fromFile != "") { + e = append(e, xerrors.Errorf("--from-prompt cannot be set along with --from-file or --from-literal")) } + if cmd.fromLiteral != "" && cmd.fromFile != "" { + e = append(e, xerrors.Errorf("--from-literal and --from-file cannot both be set")) + } + if !cmd.fromPrompt && cmd.fromFile == "" && cmd.fromLiteral == "" { + e = append(e, xerrors.Errorf("one of [--from-literal, --from-file, --from-prompt] is required")) + } + return e } func (cmd *createSecretCmd) Run(fl *pflag.FlagSet) { var ( client = requireAuth() + name = fl.Arg(0) + value string + err error ) - xvalidate.Validate(cmd) + if name == "" { + exitUsage(fl) + } + xvalidate.Validate(fl, cmd) + + if cmd.fromLiteral != "" { + value = cmd.fromLiteral + } else if cmd.fromFile != "" { + contents, err := ioutil.ReadFile(cmd.fromFile) + requireSuccess(err, "failed to read file: %v", err) + value = string(contents) + } else { + prompt := promptui.Prompt{ + Label: "value", + Mask: '*', + Validate: func(s string) error { + if len(s) < 1 { + return xerrors.Errorf("a length > 0 is required") + } + return nil + }, + } + value, err = prompt.Run() + requireSuccess(err, "failed to prompt for value: %v", err) + } - err := client.InsertSecret(entclient.InsertSecretReq{ - Name: cmd.name, - Value: cmd.value, + err = client.InsertSecret(entclient.InsertSecretReq{ + Name: name, + Value: value, Description: cmd.description, }) requireSuccess(err, "failed to insert secret: %v", err) } func (cmd *createSecretCmd) RegisterFlags(fl *pflag.FlagSet) { - fl.StringVar(&cmd.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") + fl.StringVar(&cmd.fromFile, "from-file", "", "specify a file from which to read the value of the secret") + fl.StringVar(&cmd.fromLiteral, "from-literal", "", "specify the value of the secret") + fl.BoolVar(&cmd.fromPrompt, "from-prompt", false, "specify the value of the secret through a prompt") + fl.StringVar(&cmd.description, "description", "", "specify a description of the secret") } type deleteSecretsCmd struct{} diff --git a/cmd/coder/users.go b/cmd/coder/users.go index 5c671704..e050edfb 100644 --- a/cmd/coder/users.go +++ b/cmd/coder/users.go @@ -38,7 +38,7 @@ type listCmd struct { } func (cmd *listCmd) Run(fl *pflag.FlagSet) { - xvalidate.Validate(cmd) + xvalidate.Validate(fl, cmd) entClient := requireAuth() users, err := entClient.Users() @@ -77,7 +77,7 @@ func (cmd *listCmd) Spec() cli.CommandSpec { } } -func (cmd *listCmd) Validate() (e []error) { +func (cmd *listCmd) Validate(fl *pflag.FlagSet) (e []error) { if !(cmd.outputFmt == "json" || cmd.outputFmt == "human") { e = append(e, fmt.Errorf(`--output must be "json" or "human"`)) } diff --git a/go.mod b/go.mod index 4f496165..7318d3f6 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gorilla/websocket v1.4.1 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f github.com/klauspost/compress v1.10.8 // indirect + github.com/manifoldco/promptui v0.7.0 github.com/mattn/go-colorable v0.1.6 // indirect github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 github.com/rjeczalik/notify v0.9.2 diff --git a/go.sum b/go.sum index 37756a28..cba75741 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,12 @@ github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MR github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -111,6 +117,8 @@ github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJS github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -125,6 +133,10 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/manifoldco/promptui v0.7.0 h1:3l11YT8tm9MnwGFQ4kETwkzpAwY2Jt9lCrumCUW4+z4= +github.com/manifoldco/promptui v0.7.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= @@ -225,6 +237,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03i golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/internal/x/xvalidate/errors.go b/internal/x/xvalidate/errors.go index 70aec071..d502850c 100644 --- a/internal/x/xvalidate/errors.go +++ b/internal/x/xvalidate/errors.go @@ -2,6 +2,9 @@ package xvalidate import ( "bytes" + "fmt" + + "github.com/spf13/pflag" "go.coder.com/flog" ) @@ -81,16 +84,18 @@ func combineErrors(errs ...error) error { // Validator is a command capable of validating its flags type Validator interface { - Validate() []error + Validate(fl *pflag.FlagSet) []error } // Validate performs validation and exits with a nonzero status code if validation fails. // The proper errors are printed to stderr. -func Validate(v Validator) { - errs := v.Validate() +func Validate(fl *pflag.FlagSet, v Validator) { + errs := v.Validate(fl) err := combineErrors(errs...) if err != nil { + fl.Usage() + fmt.Println("") flog.Fatal("failed to validate this command\n%v", err) } }