From 544ab60ac9ab9bff266a5e7ccfe63ec74dfa95ce Mon Sep 17 00:00:00 2001 From: David Cheung Date: Fri, 5 Jun 2020 14:39:02 -0400 Subject: [PATCH] support module-config default / value - also adding support for project conditional prompts --- .../config/projectconfig/project_config.go | 2 +- internal/context/init.go | 126 ++++++++--------- internal/context/prompts.go | 128 ++++++++++++++++++ internal/context/prompts_test.go | 61 +++++++++ internal/generate/generate_infrastructure.go | 4 +- internal/generate/generate_modules.go | 9 +- internal/module/module.go | 80 ----------- internal/module/module_test.go | 34 +++++ tests/test_data/modules/ci/zero-module.yml | 2 +- 9 files changed, 282 insertions(+), 164 deletions(-) create mode 100644 internal/context/prompts.go create mode 100644 internal/context/prompts_test.go diff --git a/internal/config/projectconfig/project_config.go b/internal/config/projectconfig/project_config.go index 40520c285..4fdd1cc74 100644 --- a/internal/config/projectconfig/project_config.go +++ b/internal/config/projectconfig/project_config.go @@ -11,7 +11,7 @@ import ( type ZeroProjectConfig struct { Name string Infrastructure Infrastructure // TODO simplify and flatten / rename? - Context map[string]string + Parameters map[string]string Modules []string } diff --git a/internal/context/init.go b/internal/context/init.go index 43ec43d80..2454f74d4 100644 --- a/internal/context/init.go +++ b/internal/context/init.go @@ -24,7 +24,8 @@ type Registry map[string][]string // Create cloud provider context func Init(outDir string) *projectconfig.ZeroProjectConfig { projectConfig := defaultProjConfig() - projectConfig.Name = promptProjectName() + + projectConfig.Name = getProjectNamePrompt().GetParam(projectConfig.Parameters) rootDir := path.Join(outDir, projectConfig.Name) flog.Infof(":tada: Creating project") @@ -36,15 +37,17 @@ func Init(outDir string) *projectconfig.ZeroProjectConfig { exit.Fatal("Error creating root: %v ", err) } - projectConfig.Context["ShouldPushRepoUpstream"] = promptPushRepoUpstream() - projectConfig.Context["GithubRootOrg"] = promptGithubRootOrg() - projectConfig.Context["githubPersonalToken"] = promptGithubPersonalToken(projectConfig.Name) - - // chooseCloudProvider(&projectConfig) - // fmt.Println(&projectConfig) - // s := project.GetSecrets(rootDir) - // fillProviderDetails(&projectConfig, s) - // fmt.Println(&projectConfig) + prompts := getProjectPrompts(projectConfig.Name) + projectConfig.Parameters["ShouldPushRepoUpstream"] = prompts["ShouldPushRepoUpstream"].GetParam(projectConfig.Parameters) + // Prompting for push-up stream, then conditionally prompting for github + projectConfig.Parameters["GithubRootOrg"] = prompts["GithubRootOrg"].GetParam(projectConfig.Parameters) + personalToken := prompts["githubPersonalToken"].GetParam(projectConfig.Parameters) + if personalToken != "" && personalToken != globalconfig.GetUserCredentials(projectConfig.Name).AccessToken { + projectConfig.Parameters["githubPersonalToken"] = personalToken + projectCredential := globalconfig.GetUserCredentials(projectConfig.Name) + projectCredential.GithubResourceConfig.AccessToken = personalToken + globalconfig.Save(projectCredential) + } moduleSources := chooseStack(getRegistry()) moduleConfigs := loadAllModules(moduleSources) for _ = range moduleConfigs { @@ -53,7 +56,7 @@ func Init(outDir string) *projectconfig.ZeroProjectConfig { projectParameters := promptAllModules(moduleConfigs) for k, v := range projectParameters { - projectConfig.Context[k] = v + projectConfig.Parameters[k] = v // TODO: Add parameters to module structs inside project } @@ -82,7 +85,7 @@ func promptAllModules(modules map[string]moduleconfig.ModuleConfig) map[string]s parameterValues := make(map[string]string) for _, config := range modules { var err error - parameterValues, err = module.PromptParams(config, parameterValues) + parameterValues, err = PromptModuleParams(config, parameterValues) if err != nil { exit.Fatal("Exiting prompt: %v\n", err) } @@ -90,69 +93,46 @@ func promptAllModules(modules map[string]moduleconfig.ModuleConfig) map[string]s return parameterValues } -// 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 := globalconfig.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 - globalconfig.Save(project) +// Project name is prompt individually because the rest of the prompts +// requires the projectName to populate defaults +func getProjectNamePrompt() PromptHandler { + return PromptHandler{ + moduleconfig.Parameter{ + Field: "projectName", + Label: "Project Name", + Default: "", + }, + NoCondition, } - return result } -func promptProjectName() string { - providerPrompt := promptui.Prompt{ - Label: "Project Name", - Default: "", - AllowEdit: false, - } - result, err := providerPrompt.Run() - if err != nil { - exit.Fatal("Prompt failed %v\n", err) +func getProjectPrompts(projectName string) map[string]PromptHandler { + return map[string]PromptHandler{ + "ShouldPushRepoUpstream": { + moduleconfig.Parameter{ + Field: "ShouldPushRepoUpstream", + Label: "Should the created projects be checked into github automatically? (y/n)", + Default: "y", + }, + NoCondition, + }, + "GithubRootOrg": { + moduleconfig.Parameter{ + Field: "GithubRootOrg", + Label: "What's the root of the github org to create repositories in?", + Default: "github.com/", + }, + KeyMatchCondition("ShouldPushRepoUpstream", "y"), + }, + "githubPersonalToken": { + moduleconfig.Parameter{ + Field: "githubPersonalToken", + Label: "Github Personal Access Token with access to the above organization", + Default: globalconfig.GetUserCredentials(projectName).AccessToken, + }, + KeyMatchCondition("ShouldPushRepoUpstream", "y"), + }, } - return result } func chooseCloudProvider(projectConfig *projectconfig.ZeroProjectConfig) { @@ -241,7 +221,7 @@ func defaultProjConfig() projectconfig.ZeroProjectConfig { Infrastructure: projectconfig.Infrastructure{ AWS: nil, }, - Context: map[string]string{}, - Modules: []string{}, + Parameters: map[string]string{}, + Modules: []string{}, } } diff --git a/internal/context/prompts.go b/internal/context/prompts.go new file mode 100644 index 000000000..7dcc7939f --- /dev/null +++ b/internal/context/prompts.go @@ -0,0 +1,128 @@ +package context + +import ( + "fmt" + "log" + "os" + "os/exec" + "regexp" + + "github.com/commitdev/zero/internal/config/moduleconfig" + "github.com/commitdev/zero/pkg/util/exit" + "github.com/manifoldco/promptui" +) + +type PromptHandler struct { + moduleconfig.Parameter + Condition func(map[string]string) bool +} + +func NoCondition(map[string]string) bool { + return true +} +func KeyMatchCondition(key string, value string) func(map[string]string) bool { + return func(param map[string]string) bool { + return param[key] == value + } +} + +// TODO: validation / allow prompt retry ...etc +func (p PromptHandler) GetParam(projectParams map[string]string) string { + var err error + var result string + if p.Condition(projectParams) { + // TODO: figure out scope of projectParams per project + // potentially dangerous to have cross module env leaking + // so if community module has an `execute: twitter tweet $ENV` + // it wouldnt leak things the module shouldnt have access to + if p.Parameter.Execute != "" { + result = executeCmd(p.Parameter.Execute, projectParams) + } else if p.Parameter.Value != "" { + result = p.Parameter.Value + } else { + err, result = promptParameter(p.Parameter) + } + if err != nil { + exit.Fatal("Exiting prompt: %v\n", err) + } + + return sanitizeParameterValue(result) + } + return "" +} + +func promptParameter(param moduleconfig.Parameter) (error, string) { + label := param.Label + if param.Label == "" { + label = param.Field + } + defaultValue := param.Default + + var err error + var result string + if len(param.Options) > 0 { + prompt := promptui.Select{ + Label: label, + Items: param.Options, + } + _, result, err = prompt.Run() + + } else { + prompt := promptui.Prompt{ + Label: label, + Default: defaultValue, + AllowEdit: true, + } + result, err = prompt.Run() + } + if err != nil { + exit.Fatal("Exiting prompt: %v\n", err) + } + + return nil, result +} + +func executeCmd(command string, envVars map[string]string) string { + cmd := exec.Command("bash", "-c", command) + cmd.Env = appendProjectEnvToCmdEnv(envVars, os.Environ()) + out, err := cmd.Output() + + if err != nil { + log.Fatalf("Failed to execute %v\n", err) + } + return string(out) +} + +// aws cli prints output with linebreak in them +func sanitizeParameterValue(str string) string { + re := regexp.MustCompile("\\n") + return re.ReplaceAllString(str, "") +} + +func appendProjectEnvToCmdEnv(envMap map[string]string, envList []string) []string { + for key, val := range envMap { + if val != "" { + envList = append(envList, fmt.Sprintf("%s=%s", key, val)) + } + } + return envList +} + +// PromptParams renders series of prompt UI based on the config +func PromptModuleParams(moduleConfig moduleconfig.ModuleConfig, parameters map[string]string) (map[string]string, error) { + + for _, promptConfig := range moduleConfig.Parameters { + // deduplicate fields already prompted and received + if _, isAlreadySet := parameters[promptConfig.Field]; isAlreadySet { + continue + } + promptHandler := PromptHandler{ + promptConfig, + NoCondition, + } + result := promptHandler.GetParam(parameters) + + parameters[promptConfig.Field] = result + } + return parameters, nil +} diff --git a/internal/context/prompts_test.go b/internal/context/prompts_test.go new file mode 100644 index 000000000..0d14a34ba --- /dev/null +++ b/internal/context/prompts_test.go @@ -0,0 +1,61 @@ +package context_test + +import ( + "testing" + + "github.com/commitdev/zero/internal/config/moduleconfig" + "github.com/commitdev/zero/internal/context" + + "github.com/stretchr/testify/assert" +) + +func TestGetParam(t *testing.T) { + projectParams := map[string]string{} + t.Run("Should execute params without prompt", func(t *testing.T) { + param := moduleconfig.Parameter{ + Field: "account-id", + Execute: "echo \"my-acconut-id\"", + } + + prompt := context.PromptHandler{ + param, + context.NoCondition, + } + + result := prompt.GetParam(projectParams) + assert.Equal(t, "my-acconut-id", result) + }) + + t.Run("executes with project context", func(t *testing.T) { + param := moduleconfig.Parameter{ + Field: "myEnv", + Execute: "echo $INJECTEDENV", + } + + prompt := context.PromptHandler{ + param, + context.NoCondition, + } + + result := prompt.GetParam(map[string]string{ + "INJECTEDENV": "SOME_ENV_VAR_VALUE", + }) + assert.Equal(t, "SOME_ENV_VAR_VALUE", result) + }) + + t.Run("Should return static value", func(t *testing.T) { + param := moduleconfig.Parameter{ + Field: "placeholder", + Value: "lorem-ipsum", + } + + prompt := context.PromptHandler{ + param, + context.NoCondition, + } + + result := prompt.GetParam(projectParams) + assert.Equal(t, "lorem-ipsum", result) + }) + +} diff --git a/internal/generate/generate_infrastructure.go b/internal/generate/generate_infrastructure.go index 063832445..dce124555 100644 --- a/internal/generate/generate_infrastructure.go +++ b/internal/generate/generate_infrastructure.go @@ -74,7 +74,7 @@ func Execute(cfg *projectconfig.ZeroProjectConfig, pathPrefix string) { "cognito_client_id", } outputValues := GetOutputs(cfg, pathPrefix, outputs) - cfg.Context["cognito_pool_id"] = outputValues["cognito_pool_id"] - cfg.Context["cognito_client_id"] = outputValues["cognito_client_id"] + cfg.Parameters["cognito_pool_id"] = outputValues["cognito_pool_id"] + cfg.Parameters["cognito_client_id"] = outputValues["cognito_client_id"] } } diff --git a/internal/generate/generate_modules.go b/internal/generate/generate_modules.go index 17c75a9f0..e9be04800 100644 --- a/internal/generate/generate_modules.go +++ b/internal/generate/generate_modules.go @@ -37,14 +37,9 @@ func GenerateModules(cfg *config.GeneratorConfig) { // Prompt for module params and execute each of the generator modules for _, mod := range templateModules { - // FIXME:(david) generate flow probably wont need prompts anymore - // added an empty map to fix test temporarily - err := mod.PromptParams(map[string]string{}) - if err != nil { - flog.Warnf("module %s: params prompt failed", mod.Source) - } + // TODO: read zero-project.yml instead - err = Generate(mod, cfg) + err := Generate(mod, cfg) if err != nil { exit.Error("module %s: %s", mod.Source, err) } diff --git a/internal/module/module.go b/internal/module/module.go index acab4acbb..3c4a18605 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -3,11 +3,8 @@ package module import ( "crypto/md5" "encoding/base64" - "fmt" "io" "log" - "os" - "os/exec" "path" "regexp" @@ -16,7 +13,6 @@ import ( "github.com/commitdev/zero/internal/constants" "github.com/commitdev/zero/internal/util" "github.com/hashicorp/go-getter" - "github.com/manifoldco/promptui" ) // TemplateModule merges a module instance params with the static configs @@ -41,82 +37,6 @@ func FetchModule(source string) (moduleconfig.ModuleConfig, error) { return config, err } -func appendProjectEnvToCmdEnv(envMap map[string]string, envList []string) []string { - for key, val := range envMap { - if val != "" { - envList = append(envList, fmt.Sprintf("%s=%s", key, val)) - } - } - return envList -} - -// aws cli prints output with linebreak in them -func sanitizePromptResult(str string) string { - re := regexp.MustCompile("\\n") - return re.ReplaceAllString(str, "") -} - -// TODO : Use this function signature instead -// PromptParams renders series of prompt UI based on the config -func PromptParams(moduleConfig moduleconfig.ModuleConfig, parameters map[string]string) (map[string]string, error) { - return map[string]string{}, nil -} - -// PromptParams renders series of prompt UI based on the config -func (m *TemplateModule) PromptParams(projectContext map[string]string) error { - for _, promptConfig := range m.Config.Parameters { - - label := promptConfig.Label - 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, - Items: promptConfig.Options, - } - _, result, err = prompt.Run() - - } else if promptConfig.Execute != "" { - // TODO: this could perhaps be set as a default for part of regular prompt - cmd := exec.Command("bash", "-c", promptConfig.Execute) - cmd.Env = appendProjectEnvToCmdEnv(projectContext, os.Environ()) - out, err := cmd.Output() - - if err != nil { - log.Fatalf("Failed to execute %v\n", err) - panic(err) - } - result = string(out) - } else { - prompt := promptui.Prompt{ - Label: label, - } - result, err = prompt.Run() - } - if err != nil { - return err - } - - result = sanitizePromptResult(result) - if m.Params == nil { - m.Params = make(map[string]string) - } - m.Params[promptConfig.Field] = result - projectContext[promptConfig.Field] = result - } - - return nil -} - // GetSourcePath gets a unique local source directory name. For local modules, it use the local directory func GetSourceDir(source string) string { if !isLocal(source) { diff --git a/internal/module/module_test.go b/internal/module/module_test.go index a75d1aff7..40ca15c9d 100644 --- a/internal/module/module_test.go +++ b/internal/module/module_test.go @@ -1,8 +1,12 @@ package module_test import ( + "errors" "testing" + "github.com/commitdev/zero/internal/config/moduleconfig" + "github.com/stretchr/testify/assert" + "github.com/commitdev/zero/internal/module" ) @@ -22,3 +26,33 @@ func TestGetSourceDir(t *testing.T) { t.Errorf("Error, remote sources should be converted to a local dir: %s", source) } } + +func TestNewTemplateModule(t *testing.T) { + testModuleSource := "../../tests/test_data/modules/ci" + var mod moduleconfig.ModuleConfig + + t.Run("Loading module from source", func(t *testing.T) { + mod, _ = module.FetchModule(testModuleSource) + + assert.Equal(t, "CI templates", mod.Name) + }) + + t.Run("Parameters are loaded", func(t *testing.T) { + param, err := findParameter(mod.Parameters, "platform") + if err != nil { + panic(err) + } + assert.Equal(t, "platform", param.Field) + assert.Equal(t, "CI Platform", param.Label) + }) + +} + +func findParameter(params []moduleconfig.Parameter, field string) (moduleconfig.Parameter, error) { + for _, v := range params { + if v.Field == field { + return v, nil + } + } + return moduleconfig.Parameter{}, errors.New("parameter not found") +} diff --git a/tests/test_data/modules/ci/zero-module.yml b/tests/test_data/modules/ci/zero-module.yml index b1d5d70e3..969969055 100644 --- a/tests/test_data/modules/ci/zero-module.yml +++ b/tests/test_data/modules/ci/zero-module.yml @@ -18,7 +18,7 @@ template: # - cognitoClientID # parameters required from user to populate the template params -prompts: +parameters: - field: platform label: CI Platform # default: github