diff --git a/ecs/cmd/commands/setup.go b/ecs/cmd/commands/setup.go index 9beec37ca..7b93284f3 100644 --- a/ecs/cmd/commands/setup.go +++ b/ecs/cmd/commands/setup.go @@ -3,20 +3,46 @@ package commands import ( "fmt" "os" + "reflect" + "strings" "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/defaults" "github.com/docker/cli/cli-plugins/plugin" contextStore "github.com/docker/ecs-plugin/pkg/docker" + "github.com/manifoldco/promptui" "github.com/spf13/cobra" "gopkg.in/ini.v1" ) +const enterLabelPrefix = "Enter " + +type setupOptions struct { + name string + context contextStore.AwsContext + accessKeyID string + secretAccessKey string +} + +func (s setupOptions) unsetRequiredArgs() []string { + unset := []string{} + if s.context.Profile == "" { + unset = append(unset, "profile") + } + if s.context.Cluster == "" { + unset = append(unset, "cluster") + } + + if s.context.Region == "" { + unset = append(unset, "region") + } + return unset +} + func SetupCommand() *cobra.Command { - var opts contextStore.AwsContext - var name string - var accessKeyID string - var secretAccessKey string + var opts setupOptions + var interactive bool cmd := &cobra.Command{ Use: "setup", @@ -27,27 +53,63 @@ func SetupCommand() *cobra.Command { return plugin.PersistentPreRunE(cmd, args) }, RunE: func(cmd *cobra.Command, args []string) error { - if accessKeyID != "" && secretAccessKey != "" { - if err := saveCredentials(opts.Profile, accessKeyID, secretAccessKey); err != nil { + if interactive { + if err := interactiveCli(&opts); err != nil { + return err + } + } else { + if requiredFlag := opts.unsetRequiredArgs(); len(requiredFlag) > 0 { + fmt.Printf("required flag(s) %q not set", requiredFlag) + cmd.Help() + os.Exit(1) + } + } + if opts.accessKeyID != "" && opts.secretAccessKey != "" { + if err := saveCredentials(opts.context.Profile, opts.accessKeyID, opts.secretAccessKey); err != nil { return err } } - return contextStore.NewContext(name, &opts) + return contextStore.NewContext(opts.name, &opts.context) }, } - cmd.Flags().StringVarP(&name, "name", "n", "aws", "Context Name") - cmd.Flags().StringVarP(&opts.Profile, "profile", "p", "", "AWS Profile") - cmd.Flags().StringVarP(&opts.Cluster, "cluster", "c", "", "ECS cluster") - cmd.Flags().StringVarP(&opts.Region, "region", "r", "", "AWS region") - cmd.Flags().StringVarP(&accessKeyID, "aws-key-id", "k", "", "AWS Access Key ID") - cmd.Flags().StringVarP(&secretAccessKey, "aws-secret-key", "s", "", "AWS Secret Access Key") + cmd.Flags().StringVarP(&opts.name, "name", "n", "aws", "Context Name") + cmd.Flags().StringVarP(&opts.context.Profile, "profile", "p", "", "AWS Profile") + cmd.Flags().StringVarP(&opts.context.Cluster, "cluster", "c", "", "ECS cluster") + cmd.Flags().StringVarP(&opts.context.Region, "region", "r", "", "AWS region") + cmd.Flags().StringVarP(&opts.accessKeyID, "aws-key-id", "k", "", "AWS Access Key ID") + cmd.Flags().StringVarP(&opts.secretAccessKey, "aws-secret-key", "s", "", "AWS Secret Access Key") + cmd.Flags().BoolVarP(&interactive, "interactive", "", false, "Interactively setup Context and Credentials") - cmd.MarkFlagRequired("profile") - cmd.MarkFlagRequired("cluster") - cmd.MarkFlagRequired("region") return cmd } +func interactiveCli(opts *setupOptions) error { + var section ini.Section + + if err := setContextName(opts); err != nil { + return err + } + + section, err := setProfile(opts, section) + if err != nil { + return err + } + + if err := setCluster(opts, err); err != nil { + return err + } + + if err := setRegion(opts, section); err != nil { + return err + } + + if err := setCredentials(opts); err != nil { + return err + } + + return nil +} + func saveCredentials(profile string, accessKeyID string, secretAccessKey string) error { p := credentials.SharedCredentialsProvider{Profile: profile} _, err := p.Retrieve() @@ -55,7 +117,8 @@ func saveCredentials(profile string, accessKeyID string, secretAccessKey string) fmt.Println("credentials already exists!") return nil } - if err.(awserr.Error).Code() == "SharedCredsLoad" { + + if err.(awserr.Error).Code() == "SharedCredsLoad" && err.(awserr.Error).Message() == "failed to load shared credentials file" { os.Create(p.Filename) } @@ -63,16 +126,146 @@ func saveCredentials(profile string, accessKeyID string, secretAccessKey string) if err != nil { return err } - section := credIni.Section(profile) - section.Key("aws_access_key_id").SetValue(accessKeyID) - section.Key("aws_secret_access_key").SetValue(secretAccessKey) - - credFile, err := os.OpenFile(p.Filename, os.O_WRONLY, 0600) + section, err := credIni.NewSection(profile) if err != nil { return err } - if _, err = credIni.WriteTo(credFile); err != nil { + section.NewKey("aws_access_key_id", accessKeyID) + section.NewKey("aws_secret_access_key", secretAccessKey) + return credIni.SaveTo(p.Filename) +} + +func awsProfiles(filename string) (map[string]ini.Section, error) { + profiles := map[string]ini.Section{"new profile": {}} + if filename == "" { + filename = defaults.SharedConfigFilename() + } + credIni, err := ini.Load(filename) + if err != nil { + return nil, err + } + if err != nil { + return nil, err + } + for _, section := range credIni.Sections() { + if strings.HasPrefix(section.Name(), "profile") { + profiles[section.Name()[len("profile "):]] = *section + } + } + return profiles, nil +} + +func setContextName(opts *setupOptions) error { + if opts.name == "aws" { + result, err := promptString(opts.name, "context name", enterLabelPrefix, 2) + if err != nil { + return err + } + opts.name = result + } + return nil +} + +func setProfile(opts *setupOptions, section ini.Section) (ini.Section, error) { + profilesList, err := awsProfiles("") + if err != nil { + return ini.Section{}, err + } + section, ok := profilesList[opts.context.Profile] + if !ok { + prompt := promptui.Select{ + Label: "Select AWS Profile", + Items: reflect.ValueOf(profilesList).MapKeys(), + } + _, result, err := prompt.Run() + if result == "new profile" { + result, err := promptString(opts.context.Profile, "profile name", enterLabelPrefix, 2) + if err != nil { + return ini.Section{}, err + } + opts.context.Profile = result + } else { + section = profilesList[result] + opts.context.Profile = result + } + if err != nil { + return ini.Section{}, err + } + } + return section, nil +} + +func setRegion(opts *setupOptions, section ini.Section) error { + defaultRegion := opts.context.Region + if defaultRegion == "" && section.Name() != "" { + region, err := section.GetKey("region") + if err == nil { + defaultRegion = region.Value() + } + } + result, err := promptString(defaultRegion, "region", enterLabelPrefix, 2) + if err != nil { return err } - return credFile.Close() + opts.context.Region = result + return nil +} + +func setCluster(opts *setupOptions, err error) error { + result, err := promptString(opts.context.Cluster, "cluster name", enterLabelPrefix, 2) + if err != nil { + return err + } + opts.context.Cluster = result + return nil +} + +func setCredentials(opts *setupOptions) error { + prompt := promptui.Prompt{ + Label: "Enter credentials", + IsConfirm: true, + } + _, err := prompt.Run() + if err == nil { + result, err := promptString(opts.accessKeyID, "AWS Access Key ID", enterLabelPrefix, 3) + if err != nil { + return err + } + opts.accessKeyID = result + + prompt = promptui.Prompt{ + Label: "Enter AWS Secret Access Key", + Validate: validateMinLen("AWS Secret Access Key", 3), + Mask: '*', + Default: opts.secretAccessKey, + } + result, err = prompt.Run() + if err != nil { + return err + } + opts.secretAccessKey = result + } + return nil +} + +func promptString(defaultValue string, label string, labelPrefix string, minLength int) (string, error) { + prompt := promptui.Prompt{ + Label: labelPrefix + label, + Validate: validateMinLen(label, minLength), + Default: defaultValue, + } + result, err := prompt.Run() + if err != nil { + return "", err + } + return result, nil +} + +func validateMinLen(label string, minLength int) func(input string) error { + return func(input string) error { + if len(input) < minLength { + return fmt.Errorf("%s must have more than %d characters", label, minLength) + } + return nil + } } diff --git a/ecs/go.mod b/ecs/go.mod index 20f071d6f..7d04a3de6 100644 --- a/ecs/go.mod +++ b/ecs/go.mod @@ -33,6 +33,7 @@ require ( github.com/jinzhu/gorm v1.9.12 // indirect github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect github.com/lib/pq v1.3.0 // indirect + github.com/manifoldco/promptui v0.7.0 github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/miekg/pkcs11 v1.0.3 // indirect github.com/mitchellh/mapstructure v1.2.2 diff --git a/ecs/go.sum b/ecs/go.sum index 5463d5c0d..2151851e9 100644 --- a/ecs/go.sum +++ b/ecs/go.sum @@ -42,6 +42,10 @@ github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEe github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/certifi/gocertifi v0.0.0-20180118203423-deb3ae2ef261/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= +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/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/backoff v0.0.0-20161212185259-647f3cdfc87a/go.mod h1:rzgs2ZOiguV6/NpiDgADjRLPNyZlApIWxKpkT+X8SdY= github.com/cloudflare/cfssl v1.4.1 h1:vScfU2DrIUI9VPHBVeeAQ0q5A+9yshO1Gz+3QoUQiKw= @@ -161,6 +165,8 @@ github.com/jmhodges/clock v0.0.0-20160418191101-880ee4c33548/go.mod h1:hGT6jSUVz github.com/jmoiron/sqlx v0.0.0-20180124204410-05cef0741ade/go.mod h1:IiEW3SEiiErVyFdH8NTuWjSifiEQKUoyK3LNqr2kCHU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= @@ -182,8 +188,16 @@ github.com/lib/pq v0.0.0-20180201184707-88edab080323/go.mod h1:5WUZQaWbwv1U+lTRe github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/manifoldco/promptui v0.7.0 h1:3l11YT8tm9MnwGFQ4kETwkzpAwY2Jt9lCrumCUW4+z4= +github.com/manifoldco/promptui v0.7.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= +github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-shellwords v1.0.10 h1:Y7Xqm8piKOO3v10Thp7Z36h4FYFjt5xB//6XvOrs2Gw= github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= @@ -355,6 +369,7 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/ecs/tests/setup_command_test.go b/ecs/tests/setup_command_test.go index 308d3e7ea..104ba2294 100644 --- a/ecs/tests/setup_command_test.go +++ b/ecs/tests/setup_command_test.go @@ -14,10 +14,11 @@ func TestSetupMandatoryArguments(t *testing.T) { defer cleanup() cmd.Command = dockerCli.Command("ecs", "setup") - icmd.RunCmd(cmd).Assert(t, icmd.Expected{ + usage := icmd.RunCmd(cmd).Assert(t, icmd.Expected{ ExitCode: 1, - Err: "required flag(s) \"cluster\", \"profile\", \"region\" not set", - }) + }).Combined() + goldenFile := "setup-required-flags.golden" + golden.Assert(t, usage, goldenFile) } func TestDefaultAwsContextName(t *testing.T) { cmd, cleanup := dockerCli.createTestCmd() diff --git a/ecs/tests/testdata/setup-required-flags.golden b/ecs/tests/testdata/setup-required-flags.golden new file mode 100644 index 000000000..8666ef14f --- /dev/null +++ b/ecs/tests/testdata/setup-required-flags.golden @@ -0,0 +1,13 @@ +required flag(s) ["profile" "cluster" "region"] not set +Usage: docker ecs setup + + + +Options: + -k, --aws-key-id string AWS Access Key ID + -s, --aws-secret-key string AWS Secret Access Key + -c, --cluster string ECS cluster + --interactive Interactively setup Context and Credentials + -n, --name string Context Name (default "aws") + -p, --profile string AWS Profile + -r, --region string AWS region