Make newcomer experience smooth

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2020-11-02 16:24:35 +01:00
parent 3066a1cdad
commit af7aebf8cf
No known key found for this signature in database
GPG Key ID: 9858809D6F8F6E7E
9 changed files with 423 additions and 101 deletions

View File

@ -20,17 +20,22 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/defaults"
"gopkg.in/ini.v1"
"github.com/docker/compose-cli/context/store"
"github.com/docker/compose-cli/errdefs"
"github.com/docker/compose-cli/prompt"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/defaults"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/pkg/errors"
"gopkg.in/ini.v1"
)
func getEnvVars() ContextParams {
@ -57,66 +62,77 @@ func getEnvVars() ContextParams {
}
type contextCreateAWSHelper struct {
user prompt.UI
user prompt.UI
availableRegions func(opts *ContextParams) ([]string, error)
}
func newContextCreateHelper() contextCreateAWSHelper {
return contextCreateAWSHelper{
user: prompt.User{},
user: prompt.User{},
availableRegions: listAvailableRegions,
}
}
func (h contextCreateAWSHelper) createContextData(_ context.Context, opts ContextParams) (interface{}, string, error) {
if opts.CredsFromEnv {
// Explicit creation from ENV variables
ecsCtx, descr := h.createContext(&opts)
return ecsCtx, descr, nil
}
if opts.Profile != "" {
} else if opts.AccessKey != "" && opts.SecretKey != "" {
// Explicit creation using keys
err := h.createProfileFromCredentials(&opts)
if err != nil {
return nil, "", err
}
} else if opts.Profile != "" {
// Excplicit creation by selecting a profile
// check profile exists
profilesList, err := getProfiles()
if err != nil {
return nil, "", err
}
if !contains(profilesList, opts.Profile) {
return nil, "", fmt.Errorf("profile %q not found", opts.Profile)
return nil, "", errors.Wrapf(errdefs.ErrNotFound, "profile %q not found", opts.Profile)
}
ecsCtx, descr := h.createContext(&opts)
return ecsCtx, descr, nil
}
options := []string{
"An existing AWS profile",
"A new AWS profile",
"AWS environment variables",
}
} else {
// interactive
var options []string
var actions []func(params *ContextParams) error
selected, err := h.user.Select("Create a Docker context using:", options)
if err != nil {
if err == terminal.InterruptErr {
return nil, "", errdefs.ErrCanceled
if _, err := os.Stat(getAWSConfigFile()); err == nil {
// User has .aws/config file, so we can offer to select one of his profiles
options = append(options, "An existing AWS profile")
actions = append(actions, h.selectFromLocalProfile)
}
options = append(options, "AWS secret and token credentials")
actions = append(actions, h.createProfileFromCredentials)
options = append(options, "AWS environment variables")
actions = append(actions, func(params *ContextParams) error {
opts.CredsFromEnv = true
return nil
})
selected, err := h.user.Select("Create a Docker context using:", options)
if err != nil {
if err == terminal.InterruptErr {
return nil, "", errdefs.ErrCanceled
}
return nil, "", err
}
err = actions[selected](&opts)
if err != nil {
return nil, "", err
}
return nil, "", err
}
switch selected {
case 0:
err = h.selectFromLocalProfile(&opts)
case 1:
err = h.createProfileFromCredentials(&opts)
case 2:
opts.CredsFromEnv = true
}
if err != nil {
return nil, "", err
}
ecsCtx, descr := h.createContext(&opts)
return ecsCtx, descr, nil
}
func (h contextCreateAWSHelper) createContext(c *ContextParams) (interface{}, string) {
if c.Profile == "default" {
c.Profile = ""
}
var description string
if c.CredsFromEnv {
@ -148,56 +164,42 @@ func (h contextCreateAWSHelper) selectFromLocalProfile(opts *ContextParams) erro
}
func (h contextCreateAWSHelper) createProfileFromCredentials(opts *ContextParams) error {
accessKey, secretKey, err := h.askCredentials()
if err != nil {
return err
}
opts.AccessKey = accessKey
opts.SecretKey = secretKey
// we need a region set -- either read it from profile or prompt user
// prompt for the region to use with this context
opts.Region, err = h.chooseRegion(opts.Region, opts.Profile)
if err != nil {
return err
}
// save as a profile
if opts.Profile == "" {
opts.Profile = opts.Name
}
// check profile does not already exist
profilesList, err := getProfiles()
if err != nil {
return err
}
if contains(profilesList, opts.Profile) {
return fmt.Errorf("profile %q already exists", opts.Profile)
}
fmt.Printf("Saving to profile %q\n", opts.Profile)
// context name used as profile name
err = h.saveCredentials(opts.Name, opts.AccessKey, opts.SecretKey)
if err != nil {
return err
}
return h.saveRegion(opts.Name, opts.Region)
}
func (h contextCreateAWSHelper) saveCredentials(profile string, accessKeyID string, secretAccessKey string) error {
p := credentials.SharedCredentialsProvider{Profile: profile}
_, err := p.Retrieve()
if err == nil {
return fmt.Errorf("credentials already exist")
if opts.AccessKey == "" || opts.SecretKey == "" {
fmt.Println("Retrieve or create AWS Access Key and Secret on https://console.aws.amazon.com/iam/home?#security_credential")
accessKey, secretKey, err := h.askCredentials()
if err != nil {
return err
}
opts.AccessKey = accessKey
opts.SecretKey = secretKey
}
if err.(awserr.Error).Code() == "SharedCredsLoad" && err.(awserr.Error).Message() == "failed to load shared credentials file" {
_, err := os.Create(p.Filename)
if opts.Region == "" {
err := h.chooseRegion(opts)
if err != nil {
return err
}
}
credIni, err := ini.Load(p.Filename)
// save as a profile
if opts.Profile == "" {
opts.Profile = "default"
}
// context name used as profile name
err := h.saveCredentials(opts.Profile, opts.AccessKey, opts.SecretKey)
if err != nil {
return err
}
return h.saveRegion(opts.Profile, opts.Region)
}
func (h contextCreateAWSHelper) saveCredentials(profile string, accessKeyID string, secretAccessKey string) error {
file := getAWSCredentialsFile()
err := os.MkdirAll(filepath.Dir(file), 0700)
if err != nil {
return err
}
credIni := ini.Empty()
section, err := credIni.NewSection(profile)
if err != nil {
return err
@ -210,7 +212,7 @@ func (h contextCreateAWSHelper) saveCredentials(profile string, accessKeyID stri
if err != nil {
return err
}
return credIni.SaveTo(p.Filename)
return credIni.SaveTo(file)
}
func (h contextCreateAWSHelper) saveRegion(profile, region string) error {
@ -218,7 +220,7 @@ func (h contextCreateAWSHelper) saveRegion(profile, region string) error {
return nil
}
// loads ~/.aws/config
awsConfig := defaults.SharedConfigFilename()
awsConfig := getAWSConfigFile()
configIni, err := ini.Load(awsConfig)
if err != nil {
if !os.IsNotExist(err) {
@ -249,8 +251,8 @@ func getProfiles() ([]string, error) {
profiles := []string{}
// parse both .aws/credentials and .aws/config for profiles
configFiles := map[string]bool{
defaults.SharedCredentialsFilename(): false,
defaults.SharedConfigFilename(): true,
getAWSCredentialsFile(): false,
getAWSConfigFile(): true,
}
for f, prefix := range configFiles {
sections, err := loadIniFile(f, prefix)
@ -267,6 +269,10 @@ func getProfiles() ([]string, error) {
}
}
}
sort.Slice(profiles, func(i, j int) bool {
return profiles[i] < profiles[j]
})
return profiles, nil
}
@ -316,32 +322,49 @@ func getRegion(profile string) (string, error) {
region := getProfileRegion(profile)
if region == "" {
region = getProfileRegion("default")
if region == "" {
return "us-east-1", nil
}
return region, nil
}
if region == "" {
// fallback to AWS default
region = "us-east-1"
}
return region, nil
}
func (h contextCreateAWSHelper) chooseRegion(region string, profile string) (string, error) {
suggestion := region
if suggestion == "" {
region, err := getRegion(profile)
if err != nil {
return "", err
}
suggestion = region
func (h contextCreateAWSHelper) chooseRegion(opts *ContextParams) error {
regions, err := h.availableRegions(opts)
if err != nil {
return err
}
// promp user for region
region, err := h.user.Input("Region", suggestion)
selected, err := h.user.Select("Region", regions)
if err != nil {
return "", err
return err
}
if region == "" {
return "", fmt.Errorf("region cannot be empty")
opts.Region = regions[selected]
return nil
}
func listAvailableRegions(opts *ContextParams) ([]string, error) {
// Setup SDK with credentials, will also validate those
session, err := session.NewSessionWithOptions(session.Options{
Config: aws.Config{
Credentials: credentials.NewStaticCredentials(opts.AccessKey, opts.SecretKey, ""),
Region: aws.String("us-east-1"),
},
})
if err != nil {
return nil, err
}
return region, nil
desc, err := ec2.New(session).DescribeRegions(&ec2.DescribeRegionsInput{})
if err != nil {
return nil, err
}
var regions []string
for _, r := range desc.Regions {
regions = append(regions, aws.StringValue(r.RegionName))
}
return regions, nil
}
func (h contextCreateAWSHelper) askCredentials() (string, string, error) {
@ -384,3 +407,19 @@ func loadIniFile(path string, prefix bool) (map[string]ini.Section, error) {
}
return profiles, nil
}
func getAWSConfigFile() string {
awsConfig, ok := os.LookupEnv("AWS_CONFIG_FILE")
if !ok {
awsConfig = defaults.SharedConfigFilename()
}
return awsConfig
}
func getAWSCredentialsFile() string {
awsConfig, ok := os.LookupEnv("AWS_SHARED_CREDENTIALS_FILE")
if !ok {
awsConfig = defaults.SharedCredentialsFilename()
}
return awsConfig
}

173
ecs/context_test.go Normal file
View File

@ -0,0 +1,173 @@
/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package ecs
import (
"context"
"os"
"testing"
"github.com/docker/compose-cli/context/store"
"github.com/docker/compose-cli/prompt"
"github.com/golang/mock/gomock"
"gotest.tools/v3/assert"
"gotest.tools/v3/fs"
"gotest.tools/v3/golden"
)
func TestCreateContextDataFromEnv(t *testing.T) {
c := contextCreateAWSHelper{
user: nil,
}
data, desc, err := c.createContextData(context.TODO(), ContextParams{
Name: "test",
CredsFromEnv: true,
})
assert.NilError(t, err)
assert.Equal(t, data.(store.EcsContext).CredentialsFromEnv, true)
assert.Equal(t, desc, "credentials read from environment")
}
func TestCreateContextDataByKeys(t *testing.T) {
dir := fs.NewDir(t, "aws")
os.Setenv("AWS_CONFIG_FILE", dir.Join("config")) // nolint:errcheck
os.Setenv("AWS_SHARED_CREDENTIALS_FILE", dir.Join("credentials")) // nolint:errcheck
defer os.Unsetenv("AWS_CONFIG_FILE") // nolint:errcheck
defer os.Unsetenv("AWS_SHARED_CREDENTIALS_FILE") // nolint:errcheck
c := contextCreateAWSHelper{
user: nil,
}
data, _, err := c.createContextData(context.TODO(), ContextParams{
Name: "test",
AccessKey: "ABCD",
SecretKey: "X&123",
Region: "eu-west-3",
})
assert.NilError(t, err)
assert.Equal(t, data.(store.EcsContext).Profile, "default")
s := golden.Get(t, dir.Join("config"))
golden.Assert(t, string(s), "context/by-keys/config.golden")
s = golden.Get(t, dir.Join("credentials"))
golden.Assert(t, string(s), "context/by-keys/credentials.golden")
}
func TestCreateContextDataFromProfile(t *testing.T) {
os.Setenv("AWS_CONFIG_FILE", "testdata/context/by-profile/config.golden") // nolint:errcheck
os.Setenv("AWS_SHARED_CREDENTIALS_FILE", "testdata/context/by-profile/credentials.golden") // nolint:errcheck
defer os.Unsetenv("AWS_CONFIG_FILE") // nolint:errcheck
defer os.Unsetenv("AWS_SHARED_CREDENTIALS_FILE") // nolint:errcheck
c := contextCreateAWSHelper{
user: nil,
}
data, _, err := c.createContextData(context.TODO(), ContextParams{
Name: "test",
Profile: "foo",
})
assert.NilError(t, err)
assert.Equal(t, data.(store.EcsContext).Profile, "foo")
}
func TestCreateContextDataFromEnvInteractive(t *testing.T) {
dir := fs.NewDir(t, "aws")
os.Setenv("AWS_CONFIG_FILE", dir.Join("config")) // nolint:errcheck
os.Setenv("AWS_SHARED_CREDENTIALS_FILE", dir.Join("credentials")) // nolint:errcheck
defer os.Unsetenv("AWS_CONFIG_FILE") // nolint:errcheck
defer os.Unsetenv("AWS_SHARED_CREDENTIALS_FILE") // nolint:errcheck
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ui := prompt.NewMockUI(ctrl)
c := contextCreateAWSHelper{
user: ui,
}
ui.EXPECT().Select("Create a Docker context using:", gomock.Any()).Return(1, nil)
data, _, err := c.createContextData(context.TODO(), ContextParams{})
assert.NilError(t, err)
assert.Equal(t, data.(store.EcsContext).CredentialsFromEnv, true)
}
func TestCreateContextDataByKeysInteractive(t *testing.T) {
dir := fs.NewDir(t, "aws")
os.Setenv("AWS_CONFIG_FILE", dir.Join("config")) // nolint:errcheck
os.Setenv("AWS_SHARED_CREDENTIALS_FILE", dir.Join("credentials")) // nolint:errcheck
defer os.Unsetenv("AWS_CONFIG_FILE") // nolint:errcheck
defer os.Unsetenv("AWS_SHARED_CREDENTIALS_FILE") // nolint:errcheck
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ui := prompt.NewMockUI(ctrl)
c := contextCreateAWSHelper{
user: ui,
availableRegions: func(opts *ContextParams) ([]string, error) {
return []string{"us-east-1", "eu-west-3"}, nil
},
}
ui.EXPECT().Select("Create a Docker context using:", gomock.Any()).Return(0, nil)
ui.EXPECT().Input("AWS Access Key ID", gomock.Any()).Return("ABCD", nil)
ui.EXPECT().Password("Enter AWS Secret Access Key").Return("X&123", nil)
ui.EXPECT().Select("Region", []string{"us-east-1", "eu-west-3"}).Return(1, nil)
data, _, err := c.createContextData(context.TODO(), ContextParams{})
assert.NilError(t, err)
assert.Equal(t, data.(store.EcsContext).Profile, "default")
assert.NilError(t, err)
assert.Equal(t, data.(store.EcsContext).Profile, "default")
s := golden.Get(t, dir.Join("config"))
golden.Assert(t, string(s), "context/by-keys/config.golden")
s = golden.Get(t, dir.Join("credentials"))
golden.Assert(t, string(s), "context/by-keys/credentials.golden")
}
func TestCreateContextDataByProfileInteractive(t *testing.T) {
os.Setenv("AWS_CONFIG_FILE", "testdata/context/by-profile/config.golden") // nolint:errcheck
os.Setenv("AWS_SHARED_CREDENTIALS_FILE", "testdata/context/by-profile/credentials.golden") // nolint:errcheck
defer os.Unsetenv("AWS_CONFIG_FILE") // nolint:errcheck
defer os.Unsetenv("AWS_SHARED_CREDENTIALS_FILE") // nolint:errcheck
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ui := prompt.NewMockUI(ctrl)
c := contextCreateAWSHelper{
user: ui,
}
ui.EXPECT().Select("Create a Docker context using:", gomock.Any()).Return(0, nil)
ui.EXPECT().Select("Select AWS Profile", []string{"default", "foo"}).Return(1, nil)
data, _, err := c.createContextData(context.TODO(), ContextParams{})
assert.NilError(t, err)
assert.Equal(t, data.(store.EcsContext).Profile, "foo")
}

View File

@ -0,0 +1,3 @@
[profile default]
region = eu-west-3

View File

@ -0,0 +1,4 @@
[default]
aws_access_key_id = ABCD
aws_secret_access_key = X&123

View File

@ -0,0 +1,3 @@
[profile foo]
region = eu-west-3

View File

@ -0,0 +1,4 @@
[foo]
aws_access_key_id = ABCD
aws_secret_access_key = X&123

1
go.sum
View File

@ -746,6 +746,7 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20200717024301-6ddee64345a6/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d h1:W07d4xkoAUSNOkOzdzXCdFGxT7o2rW4q8M34tB2i//k=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -20,6 +20,8 @@ import (
"github.com/AlecAivazis/survey/v2"
)
//go:generate mockgen -destination=./prompt_mock.go -self_package "github.com/docker/compose-cli/prompt" -package=prompt . UI
// UI - prompt user input
type UI interface {
Select(message string, options []string) (int, error)

93
prompt/prompt_mock.go Normal file
View File

@ -0,0 +1,93 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/docker/compose-cli/prompt (interfaces: UI)
// Package prompt is a generated GoMock package.
package prompt
import (
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// MockUI is a mock of UI interface
type MockUI struct {
ctrl *gomock.Controller
recorder *MockUIMockRecorder
}
// MockUIMockRecorder is the mock recorder for MockUI
type MockUIMockRecorder struct {
mock *MockUI
}
// NewMockUI creates a new mock instance
func NewMockUI(ctrl *gomock.Controller) *MockUI {
mock := &MockUI{ctrl: ctrl}
mock.recorder = &MockUIMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockUI) EXPECT() *MockUIMockRecorder {
return m.recorder
}
// Confirm mocks base method
func (m *MockUI) Confirm(arg0 string, arg1 bool) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Confirm", arg0, arg1)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Confirm indicates an expected call of Confirm
func (mr *MockUIMockRecorder) Confirm(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Confirm", reflect.TypeOf((*MockUI)(nil).Confirm), arg0, arg1)
}
// Input mocks base method
func (m *MockUI) Input(arg0, arg1 string) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Input", arg0, arg1)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Input indicates an expected call of Input
func (mr *MockUIMockRecorder) Input(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Input", reflect.TypeOf((*MockUI)(nil).Input), arg0, arg1)
}
// Password mocks base method
func (m *MockUI) Password(arg0 string) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Password", arg0)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Password indicates an expected call of Password
func (mr *MockUIMockRecorder) Password(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Password", reflect.TypeOf((*MockUI)(nil).Password), arg0)
}
// Select mocks base method
func (m *MockUI) Select(arg0 string, arg1 []string) (int, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Select", arg0, arg1)
ret0, _ := ret[0].(int)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Select indicates an expected call of Select
func (mr *MockUIMockRecorder) Select(arg0, arg1 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Select", reflect.TypeOf((*MockUI)(nil).Select), arg0, arg1)
}