From 2211d537f829c546b098c0de38d79c92c20e95bf Mon Sep 17 00:00:00 2001 From: David Cheung Date: Thu, 28 May 2020 18:29:03 -0400 Subject: [PATCH] zero init features: dedupe/new prompts/config file --- configs/configs.go | 10 +- go.mod | 2 +- go.sum | 2 + internal/config/global_config.go | 123 ++++++++++++++++++++++++ internal/config/global_config_test.go | 103 ++++++++++++++++++++ internal/config/init_test.go | 3 +- internal/context/init.go | 102 ++++++++++++++++---- internal/module/module.go | 7 +- tests/test_data/configs/credentials.yml | 11 +++ 9 files changed, 336 insertions(+), 27 deletions(-) create mode 100644 internal/config/global_config.go create mode 100644 internal/config/global_config_test.go create mode 100644 tests/test_data/configs/credentials.yml diff --git a/configs/configs.go b/configs/configs.go index 225db1c23..6084e1aa6 100644 --- a/configs/configs.go +++ b/configs/configs.go @@ -1,8 +1,10 @@ package configs const ( - TemplatesDir = "tmp/templates" - ZeroProjectYml = "zero-project.yml" - IgnoredPaths = "(?i)zero.module.yml|.git/" - TemplateExtn = ".tmpl" + TemplatesDir = "tmp/templates" + ZeroProjectYml = "zero-project.yml" + ZeroHomeDirectory = ".zero" + UserCredentials = "credentials.yml" + IgnoredPaths = "(?i)zero.module.yml|.git/" + TemplateExtn = ".tmpl" ) diff --git a/go.mod b/go.mod index f2e61d127..9abb24a48 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/matryer/is v1.3.0 // indirect github.com/mattn/go-colorable v0.1.2 // indirect github.com/spf13/cobra v0.0.6 - github.com/stretchr/testify v1.5.1 // indirect + github.com/stretchr/testify v1.4.0 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b // indirect golang.org/x/sys v0.0.0-20191010194322-b09406accb47 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect diff --git a/go.sum b/go.sum index f48e8332e..6e2005733 100644 --- a/go.sum +++ b/go.sum @@ -181,6 +181,8 @@ github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/y github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= diff --git a/internal/config/global_config.go b/internal/config/global_config.go new file mode 100644 index 000000000..4f266380b --- /dev/null +++ b/internal/config/global_config.go @@ -0,0 +1,123 @@ +package config + +import ( + "bytes" + "io/ioutil" + "log" + "os" + "os/user" + "path" + + "github.com/commitdev/zero/configs" + "github.com/commitdev/zero/pkg/util/exit" + yaml "gopkg.in/yaml.v2" +) + +var GetCredentialsPath = getCredentialsPath + +type ProjectCredentials map[string]ProjectCredential + +type ProjectCredential struct { + ProjectName string `yaml:"-"` + AWSResourceConfig `yaml:"aws,omitempty"` + GithubResourceConfig `yaml:"github,omitempty"` + CircleCiResourceConfig `yaml:"circleci,omitempty"` +} + +type AWSResourceConfig struct { + AccessKeyId string `yaml:"accessKeyId,omitempty"` + SecretAccessKey string `yaml:"secretAccessKey,omitempty"` +} +type GithubResourceConfig struct { + AccessToken string `yaml:"accessToken,omitempty"` +} +type CircleCiResourceConfig struct { + ApiKey string `yaml:"apiKey,omitempty"` +} + +func (p ProjectCredentials) Unmarshal(data []byte) error { + if len(data) == 0 { + return nil + } + err := yaml.NewDecoder(bytes.NewReader(data)).Decode(p) + if err != nil { + return err + } + for k, v := range p { + v.ProjectName = k + p[k] = v + } + return nil +} + +func LoadUserCredentials() ProjectCredentials { + data := readOrCreateUserCredentialsFile() + + projects := ProjectCredentials{} + + err := projects.Unmarshal(data) + + if err != nil { + exit.Fatal("Failed to parse configuration: %v", err) + } + return projects +} + +func getCredentialsPath() string { + usr, err := user.Current() + if err != nil { + exit.Fatal("Failed to get user directory path: %v", err) + } + + rootDir := path.Join(usr.HomeDir, configs.ZeroHomeDirectory) + os.MkdirAll(rootDir, os.ModePerm) + filePath := path.Join(rootDir, configs.UserCredentials) + return filePath +} + +func readOrCreateUserCredentialsFile() []byte { + credPath := GetCredentialsPath() + + _, fileStateErr := os.Stat(credPath) + if os.IsNotExist(fileStateErr) { + var file, fileStateErr = os.Create(credPath) + if fileStateErr != nil { + exit.Fatal("Failed to create config file: %v", fileStateErr) + } + defer file.Close() + } + data, err := ioutil.ReadFile(credPath) + if err != nil { + exit.Fatal("Failed to read credentials file: %v", err) + } + return data +} + +func GetUserCredentials(targetProjectName string) ProjectCredential { + projects := LoadUserCredentials() + + if val, ok := projects[targetProjectName]; ok { + return val + } else { + p := ProjectCredential{ + ProjectName: targetProjectName, + } + projects[targetProjectName] = p + return p + } +} + +func Save(project ProjectCredential) { + projects := LoadUserCredentials() + projects[project.ProjectName] = project + writeCredentialsFile(projects) +} + +func writeCredentialsFile(projects ProjectCredentials) { + credsPath := GetCredentialsPath() + content, _ := yaml.Marshal(projects) + err := ioutil.WriteFile(credsPath, content, 0644) + if err != nil { + log.Panicf("failed to write config: %v", err) + } +} diff --git a/internal/config/global_config_test.go b/internal/config/global_config_test.go new file mode 100644 index 000000000..92921a3ae --- /dev/null +++ b/internal/config/global_config_test.go @@ -0,0 +1,103 @@ +package config_test + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "path" + "testing" + + "github.com/commitdev/zero/internal/config" + "github.com/stretchr/testify/assert" +) + +const baseTestFixturesDir = "../../tests/test_data/configs/" + +var testCredentialFile = func() (func() string, func()) { + tmpConfigPath := getTmpConfig() + mockFunc := func() string { return tmpConfigPath } + teardownFunc := func() { os.RemoveAll(tmpConfigPath) } + return mockFunc, teardownFunc +} + +func getTmpConfig() string { + pathFrom := path.Join(baseTestFixturesDir, fmt.Sprintf("credentials%s.yml", "")) + pathTo := path.Join(baseTestFixturesDir, fmt.Sprintf("credentials%s.yml", "-tmp")) + copyFile(pathFrom, pathTo) + return pathTo +} + +func copyFile(from string, to string) { + bytesRead, err := ioutil.ReadFile(from) + if err != nil { + log.Fatal(err) + } + + err = ioutil.WriteFile(to, bytesRead, 0644) + if err != nil { + log.Fatal(err) + } +} +func TestReadOrCreateUserCredentialsFile(t *testing.T) { + config.GetCredentialsPath = func() string { + return path.Join(baseTestFixturesDir, "does-not-exist.yml") + } + credPath := config.GetCredentialsPath() + + defer os.RemoveAll(credPath) + _, fileStateErr := os.Stat(credPath) + assert.True(t, os.IsNotExist(fileStateErr), "File should not exist") + // attempting to read the file should create the file + config.GetUserCredentials("any-project") + + stats, err := os.Stat(credPath) + assert.False(t, os.IsNotExist(err), "File should be created") + assert.Equal(t, "does-not-exist.yml", stats.Name(), "Should create yml automatically") +} + +func TestGetUserCredentials(t *testing.T) { + var teardownFn func() + config.GetCredentialsPath, teardownFn = testCredentialFile() + defer teardownFn() + + t.Run("Fixture file should have existing project with creds", func(t *testing.T) { + projectName := "my-project" + project := config.GetUserCredentials(projectName) + + // Reading from fixtures: tests/test_data/configs/credentials.yml + assert.Equal(t, "AKIAABCD", project.AWSResourceConfig.AccessKeyId) + assert.Equal(t, "ZXCV", project.AWSResourceConfig.SecretAccessKey) + assert.Equal(t, "0987", project.GithubResourceConfig.AccessToken) + assert.Equal(t, "SOME_API_KEY", project.CircleCiResourceConfig.ApiKey) + }) + + t.Run("Fixture file should support multiple projects", func(t *testing.T) { + projectName := "another-project" + project := config.GetUserCredentials(projectName) + assert.Equal(t, "654", project.GithubResourceConfig.AccessToken) + }) +} + +func TestEditUserCredentials(t *testing.T) { + var teardownFn func() + config.GetCredentialsPath, teardownFn = testCredentialFile() + defer teardownFn() + + t.Run("Should create new project if not exist", func(t *testing.T) { + projectName := "test-project3" + project := config.GetUserCredentials(projectName) + project.AWSResourceConfig.AccessKeyId = "TEST_KEY_ID_1" + config.Save(project) + newKeyID := config.GetUserCredentials(projectName).AWSResourceConfig.AccessKeyId + assert.Equal(t, "TEST_KEY_ID_1", newKeyID) + }) + t.Run("Should edit old project if already exist", func(t *testing.T) { + projectName := "my-project" + project := config.GetUserCredentials(projectName) + project.AWSResourceConfig.AccessKeyId = "EDITED_ACCESS_KEY_ID" + config.Save(project) + newKeyID := config.GetUserCredentials(projectName).AWSResourceConfig.AccessKeyId + assert.Equal(t, "EDITED_ACCESS_KEY_ID", newKeyID) + }) +} diff --git a/internal/config/init_test.go b/internal/config/init_test.go index 62450e45d..216231f8f 100644 --- a/internal/config/init_test.go +++ b/internal/config/init_test.go @@ -23,7 +23,8 @@ func TestInit(t *testing.T) { t.Fatal(err) } - config.Init(config.RootDir, projectName, nil) + projectConfig := config.ZeroProjectConfig{} + config.Init(config.RootDir, projectName, &projectConfig) if _, err := os.Stat(path.Join(testDirPath, configs.ZeroProjectYml)); err != nil { t.Fatal(err) diff --git a/internal/context/init.go b/internal/context/init.go index 36379fc96..ea961c54a 100644 --- a/internal/context/init.go +++ b/internal/context/init.go @@ -1,7 +1,6 @@ package context import ( - "log" "os" "path" @@ -18,6 +17,8 @@ import ( "github.com/manifoldco/promptui" ) +type Registry map[string][]string + // Create cloud provider context func Init(projectName string, outDir string) *config.ZeroProjectConfig { rootDir := path.Join(outDir, projectName) @@ -31,11 +32,11 @@ func Init(projectName string, outDir string) *config.ZeroProjectConfig { } projectConfig := defaultProjConfig(projectName) - promptProjectName(projectName, &projectConfig) - chooseStack(&projectConfig) - - // TODO: load ~/.zero/config.yml (or credentials) - // TODO: prompt global credentials + projectConfig.Name = promptProjectName(projectName) + projectConfig.Context["ShouldPushRepoUpstream"] = promptPushRepoUpstream() + projectConfig.Context["GithubRootOrg"] = promptGithubRootOrg() + projectConfig.Context["githubPersonalToken"] = promptGithubPersonalToken(projectName) + projectConfig.Modules = chooseStack(getRegistry()) // chooseCloudProvider(&projectConfig) // fmt.Println(&projectConfig) @@ -44,6 +45,8 @@ func Init(projectName string, outDir string) *config.ZeroProjectConfig { // fmt.Println(&projectConfig) promptAllModules(&projectConfig) + // TODO: load ~/.zero/config.yml (or credentials) + // TODO: prompt global credentials return &projectConfig } @@ -55,19 +58,74 @@ func promptAllModules(projectConfig *config.ZeroProjectConfig) { mod, _ := module.NewTemplateModule(config.ModuleInstance{Source: moduleSource}) err := mod.PromptParams(projectConfig.Context) if err != nil { - log.Fatalf("Exiting prompt: %v\n", err) - panic(err) + exit.Fatal("Exiting prompt: %v\n", err) } } } -func promptProjectName(projectName string, projectConfig *config.ZeroProjectConfig) { +// global configs +func promptPushRepoUpstream() string { + providerPrompt := promptui.Prompt{ + Label: "Should the created projects be checked into github automatically? (y/n)", + Default: "y", + AllowEdit: false, + } + providerResult, err := providerPrompt.Run() + if err != nil { + exit.Fatal("Exiting prompt: %v\n", err) + } + return providerResult +} + +func promptGithubRootOrg() string { + providerPrompt := promptui.Prompt{ + Label: "What's the root of the github org to create repositories in?", + Default: "github.com/", + AllowEdit: true, + } + result, err := providerPrompt.Run() + if err != nil { + exit.Fatal("Exiting prompt: %v\n", err) + } + return result +} + +func promptGithubPersonalToken(projectName string) string { + defaultToken := "" + + project := config.GetUserCredentials(projectName) + if project.GithubResourceConfig.AccessToken != "" { + defaultToken = project.GithubResourceConfig.AccessToken + } + + providerPrompt := promptui.Prompt{ + Label: "Github Personal Access Token with access to the above organization", + Default: defaultToken, + } + result, err := providerPrompt.Run() + if err != nil { + exit.Fatal("Prompt failed %v\n", err) + } + + // If its different from saved token, update it + if project.GithubResourceConfig.AccessToken != result { + project.GithubResourceConfig.AccessToken = result + config.Save(project) + } + return result +} + +func promptProjectName(projectName string) string { providerPrompt := promptui.Prompt{ Label: "Project Name", Default: projectName, AllowEdit: false, } - providerPrompt.Run() + result, err := providerPrompt.Run() + if err != nil { + exit.Fatal("Prompt failed %v\n", err) + } + return result } func chooseCloudProvider(projectConfig *config.ZeroProjectConfig) { @@ -79,8 +137,7 @@ func chooseCloudProvider(projectConfig *config.ZeroProjectConfig) { _, providerResult, err := providerPrompt.Run() if err != nil { - log.Fatalf("Prompt failed %v\n", err) - panic(err) + exit.Fatal("Prompt failed %v\n", err) } if providerResult != "Amazon AWS" { @@ -88,8 +145,8 @@ func chooseCloudProvider(projectConfig *config.ZeroProjectConfig) { } } -func chooseStack(projectConfig *config.ZeroProjectConfig) { - items := map[string][]string{ +func getRegistry() Registry { + return Registry{ // TODO: better place to store these options as configuration file or any source "EKS + Go + React": []string{ "github.com/commitdev/zero-aws-eks-stack", @@ -98,24 +155,29 @@ func chooseStack(projectConfig *config.ZeroProjectConfig) { }, "Custom": []string{}, } +} - labels := make([]string, len(items)) +func (registry Registry) availableLabels() []string { + labels := make([]string, len(registry)) i := 0 - for label := range items { + for label := range registry { labels[i] = label i++ } + return labels +} +func chooseStack(registry Registry) []string { providerPrompt := promptui.Select{ Label: "Pick a stack you'd like to use", - Items: labels, + Items: registry.availableLabels(), } _, providerResult, err := providerPrompt.Run() if err != nil { - log.Fatalf("Prompt failed %v\n", err) - panic(err) + exit.Fatal("Prompt failed %v\n", err) } - projectConfig.Modules = items[providerResult] + return registry[providerResult] + } func fillProviderDetails(projectConfig *config.ZeroProjectConfig, s project.Secrets) { diff --git a/internal/module/module.go b/internal/module/module.go index 7c426fcdd..de3db9c9a 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -78,9 +78,14 @@ func (m *TemplateModule) PromptParams(projectContext map[string]string) error { if promptConfig.Label == "" { label = promptConfig.Field } + + // deduplicate fields already prompted and received + if _, isAlreadySet := projectContext[promptConfig.Field]; isAlreadySet { + continue + } + var err error var result string - if len(promptConfig.Options) > 0 { prompt := promptui.Select{ Label: label, diff --git a/tests/test_data/configs/credentials.yml b/tests/test_data/configs/credentials.yml new file mode 100644 index 000000000..2a7092439 --- /dev/null +++ b/tests/test_data/configs/credentials.yml @@ -0,0 +1,11 @@ +another-project: + github: + accessToken: "654" +my-project: + aws: + accessKeyId: AKIAABCD + secretAccessKey: ZXCV + github: + accessToken: "0987" + circleci: + apiKey: SOME_API_KEY