Add interactive context setup

Signed-off-by: Guillaume Lours <guillaume.lours@docker.com>
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Guillaume Lours 2020-05-10 23:31:10 +02:00 committed by Nicolas De Loof
parent 895dc249b4
commit 51e04a4702
No known key found for this signature in database
GPG Key ID: 9858809D6F8F6E7E
5 changed files with 250 additions and 27 deletions

View File

@ -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
}
}

View File

@ -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

View File

@ -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=

View File

@ -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()

View File

@ -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