Browse Source

Add interactive context setup

Signed-off-by: Guillaume Lours <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
Guillaume Lours 5 years ago
parent
commit
51e04a4702
5 changed files with 249 additions and 26 deletions
  1. 216 23
      ecs/cmd/commands/setup.go
  2. 1 0
      ecs/go.mod
  3. 15 0
      ecs/go.sum
  4. 4 3
      ecs/tests/setup_command_test.go
  5. 13 0
      ecs/tests/testdata/setup-required-flags.golden

+ 216 - 23
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)
+				}
 			}
-			return contextStore.NewContext(name, &opts)
+			if opts.accessKeyID != "" && opts.secretAccessKey != "" {
+				if err := saveCredentials(opts.context.Profile, opts.accessKeyID, opts.secretAccessKey); err != nil {
+					return err
+				}
+			}
+			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)
+	section, err := credIni.NewSection(profile)
+	if err != nil {
+		return err
+	}
+	section.NewKey("aws_access_key_id", accessKeyID)
+	section.NewKey("aws_secret_access_key", secretAccessKey)
+	return credIni.SaveTo(p.Filename)
+}
 
-	credFile, err := os.OpenFile(p.Filename, os.O_WRONLY, 0600)
+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
 	}
-	if _, err = credIni.WriteTo(credFile); err != nil {
+	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
 	}
-	return credFile.Close()
+	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
+	}
 }

+ 1 - 0
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

+ 15 - 0
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=

+ 4 - 3
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()

+ 13 - 0
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