diff --git a/README.md b/README.md index 418702f20..2b161034f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,18 @@ You need to [register a new domain](https://docs.aws.amazon.com/Route53/latest/D ___ +### Building blocks of Zero + +### Project Definition: +Each project is defined by this project definition file, this manifest contains your project details, and is the source of truth for the templating(`zero create`) and provision(`zero apply`) steps. + +See [`zero-project.yml` reference](./docs/project-definition.md) for details. +### Module Definition +Module definition defines the information needed for the module to run (`zero apply`). +Also declares dependency used to determine the order of execution with other modules. + +See [`zero-module.yml` reference](./docs/module-definition.md) for details. +___ ## Using zero to spin up your own stack Using Zero to spin up your infrastructure and application is easy and straightforward. Using just a few commands, you can configure and deploy your very own scalable, high-performance, production-ready infrastructure. diff --git a/cmd/create.go b/cmd/create.go index 78c2066e4..abfb7c6cc 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -5,7 +5,6 @@ import ( "path" "strings" - "github.com/commitdev/zero/internal/config/globalconfig" "github.com/commitdev/zero/internal/config/projectconfig" "github.com/commitdev/zero/internal/constants" "github.com/commitdev/zero/internal/generate" @@ -47,9 +46,12 @@ func Create(dir string, createConfigPath string) { if projectConfig.ShouldPushRepositories { flog.Infof(":up_arrow: Done Rendering - committing repositories to version control.") - globalConfig := globalconfig.GetProjectCredentials(projectConfig.Name) for _, module := range projectConfig.Modules { - vcs.InitializeRepository(module.Files.Repository, globalConfig.GithubResourceConfig.AccessToken) + err, githubApiKey := projectconfig.ReadVendorCredentialsFromModule(module, "github") + if err != nil { + flog.Errorf(err.Error()) + } + vcs.InitializeRepository(module.Files.Repository, githubApiKey) } } else { flog.Infof(":up_arrow: Done Rendering - you will need to commit the created projects to version control.") diff --git a/docs/module-definition.md b/docs/module-definition.md new file mode 100644 index 000000000..b59cb30c4 --- /dev/null +++ b/docs/module-definition.md @@ -0,0 +1,79 @@ +## Module Definition: `zero-module.yml` +This file is the definition of a Zero module. It contains a list of all the required parameters to be able to prompt a user for choices during `zero init`, information about how to template the contents of the module during `zero create`, and the information needed for the module to run (`zero apply`). +It also declares the module's dependencies to determine the order of execution in relation to other modules. + +| Parameters | type | Description | +|---------------|-----------------|--------------------------------------------------| +| `name` | string | Name of module | +| `description` | string | Description of the module | +| `template` | template | default settings for templating out the module | +| `author` | string | Author of the module | +| `icon` | string | Path to logo image | +| `parameters` | list(Parameter) | Parameters to prompt users | + + +### Template +| Parameters | Type | Description | +|--------------|---------|-----------------------------------------------------------------------| +| `strictMode` | boolean | whether strict mode is enabled | +| `delimiters` | tuple | A tuple of open delimiter and ending delimiter eg: `<%` and `%>` | +| `inputDir` | string | Folder to template from the module, becomes the module root for users | +| `outputDir` | string | local directory name for the module, gets commited to version control | + +### Condition(module) +Module conditions are considered during template phase (`zero create`), based on parameters supplied from project-definition, +modules can decide to have specific files ignored from the user's module. For example if user picks `userAuth: no`, we can ignore the auth resources via templating. + +| Parameters | Type | Description | +|--------------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | enum(string) | type of condition, currently supports [`ignoreFile`] | +| `matchField` | string | Allows you to condition prompt based on another parameter's value | +| `WhenValue` | string | Matches for this value to satisfy the condition | +| `data` | list(string) | Supply extra data for condition to run `ignoreFile`: provide list of paths (file or directory path) to omit from module when condition is satisfied | + +### Parameter: +Parameter defines the prompt during zero-init. +There are multiple ways of obtaining the value for each parameter. +Parameters may have `Conditions` and must be fulfilled when supplied, otherwise it skips the field entirely. + +The precedence for different types of parameter prompts are as follow. +1. Execute +2. type: specific ways of obtaining values (in AWS credential case it will set 2 values to the map) +3. value: directly assigns a value to a parameter +4. prompt: requires users to select an option OR input a string +Note: Default is supplied as the starting point of the user's manual input (Not when value passed in is empty) + +| Parameters | Type | Description | +|-----------------------|-----------------|---------------------------------------------------------------------------------------------------------------------------| +| `field` | string | key to store result for project definition | +| `label` | string | displayed name for the prompt | +| `options` | list(string) | A list of values for users to pick from | +| `default` | string | Defaults to this value during prompt | +| `value` | string | Skips prompt entirely when set | +| `info` | string | Displays during prompt as extra information guiding user's input | +| `fieldValidation` | Validation | Validations for the prompt value | +| `type` | enum(string) | Built in custom prompts: currently supports [`AWSProfilePicker`] | +| `execute` | string | executes commands and takes stdout as prompt result | +| `omitFromProjectFile` | bool | Field is skipped from adding to project definition | +| `conditions` | list(Condition) | Conditions for prompt to run, if supplied all conditions must pass | +| `envVarName` | string | During `zero apply` parameters are available as env-vars, defaults to field name but can be overwritten with `envVarName` | + +### Condition(paramters) +Parameters conditions are considered while running user prompts, prompts are +executed in order of the yml, and will be skipped if conditions are not satisfied. +For example if a user decide to not use circleCI, condition can be used to skip the circleCI_api_key prompt. + +| Parameters | Type | Description | +|--------------|--------------|-------------------------------------------------------------------| +| `action` | enum(string) | type of condition, currently supports [`KeyMatchCondition`] | +| `matchField` | string | Allows you to condition prompt based on another parameter's value | +| `WhenValue` | string | Matches for this value to satisfy the condition | +| `data` | list(string) | Supply extra data for condition to run | + +### Validation + +| Parameters | type | Description | +|----------------|--------------|-------------------------------------| +| `type` | enum(string) | Currently supports [`regex`] | +| `value` | string | Regular expression string | +| `errorMessage` | string | Error message when validation fails | diff --git a/docs/project-definition.md b/docs/project-definition.md new file mode 100644 index 000000000..2ca3bf641 --- /dev/null +++ b/docs/project-definition.md @@ -0,0 +1,27 @@ +### Project Definition: `zero-project.yml` +Each project is defined by this file. This manifest reflects all the options a user chose during the `zero init` step. It defines which modules are part of the project, each of their parameters, and is the source of truth for the templating (`zero create`) and provision (`zero apply`) steps. + +_Note: This file contains credentials, so make sure it is not shared with others._ + +| Parameters | Type | Description | +|--------------------------|--------------|------------------------------------------------| +| `name` | string | name of the project | +| `shouldPushRepositories` | boolean | whether to push the modules to version control | +| `modules` | map(modules) | a map containing modules of your project | + + +### Modules +| Parameters | Type | Description | +|--------------|-----------------|-------------------------------------------------------------------------| +| `parameters` | map(string) | key-value map of all the parameters to run the module | +| `files` | File | Stores information such as source-module location and destination | +| `dependsOn` | list(string) | a list of dependencies that should be fulfilled before this module | +| `conditions` | list(condition) | conditions to apply while templating out the module based on parameters | + +### Condition +| Parameters | Type | Description | +|--------------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| `action` | enum(string) | type of condition, currently supports [`ignoreFile`] | +| `matchField` | string | Allows you to condition prompt based on another parameter's value | +| `WhenValue` | string | Matches for this value to satisfy the condition | +| `data` | list(string) | Supply extra data for condition to run `ignoreFile`: provide list of paths (file or directory path) to omit from module when condition is satisfied | diff --git a/go.mod b/go.mod index 33a65a2ce..04972d7d5 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/matryer/is v1.3.0 // indirect github.com/mattn/go-colorable v0.1.2 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.2.0 github.com/spf13/cobra v0.0.6 github.com/stretchr/testify v1.5.1 diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 77ad4a2e3..782153369 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -13,7 +13,6 @@ import ( "github.com/commitdev/zero/internal/util" "github.com/hashicorp/terraform/dag" - "github.com/commitdev/zero/internal/config/globalconfig" "github.com/commitdev/zero/internal/config/projectconfig" "github.com/commitdev/zero/pkg/util/exit" "github.com/commitdev/zero/pkg/util/flog" @@ -87,11 +86,8 @@ func applyAll(dir string, projectConfig projectconfig.ZeroProjectConfig, applyEn exit.Fatal("Failed to load module config, credentials cannot be injected properly") } - // Get project credentials for the makefile - credentials := globalconfig.GetProjectCredentials(projectConfig.Name) - credentialEnvs := credentials.SelectedVendorsCredentialsAsEnv(modConfig.RequiredCredentials) - envList = util.AppendProjectEnvToCmdEnv(mod.Parameters, envList) - envList = util.AppendProjectEnvToCmdEnv(credentialEnvs, envList) + envVarTranslationMap := modConfig.GetParamEnvVarTranslationMap() + envList = util.AppendProjectEnvToCmdEnv(mod.Parameters, envList, envVarTranslationMap) flog.Debugf("Env injected: %#v", envList) flog.Infof("Executing apply command for %s...", modConfig.Name) util.ExecuteCommand(exec.Command("make"), modulePath, envList) @@ -160,7 +156,12 @@ func summarizeAll(dir string, projectConfig projectconfig.ZeroProjectConfig, app } flog.Debugf("Loaded module: %s from %s", name, modulePath) - envList = util.AppendProjectEnvToCmdEnv(mod.Parameters, envList) + modConfig, err := module.ParseModuleConfig(modulePath) + if err != nil { + exit.Fatal("Failed to load module config, credentials cannot be injected properly") + } + envVarTranslationMap := modConfig.GetParamEnvVarTranslationMap() + envList = util.AppendProjectEnvToCmdEnv(mod.Parameters, envList, envVarTranslationMap) flog.Debugf("Env injected: %#v", envList) util.ExecuteCommand(exec.Command("make", "summary"), modulePath, envList) return nil diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index a88daee54..a4c1b41e4 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -38,4 +38,11 @@ func TestApply(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "baz: qux\n", string(content)) }) + + t.Run("Zero apply honors the envVarName overwrite from module definition", func(t *testing.T) { + content, err := ioutil.ReadFile(filepath.Join(tmpDir, "project1/feature.out")) + assert.NoError(t, err) + assert.Equal(t, "envVarName of viaEnvVarName: baz\n", string(content)) + }) + } diff --git a/internal/config/globalconfig/global_config.go b/internal/config/globalconfig/global_config.go deleted file mode 100644 index e67377776..000000000 --- a/internal/config/globalconfig/global_config.go +++ /dev/null @@ -1,183 +0,0 @@ -package globalconfig - -import ( - "bytes" - "io/ioutil" - "log" - "os" - "os/user" - "path" - "reflect" - "strings" - - "github.com/commitdev/zero/internal/constants" - "github.com/commitdev/zero/internal/util" - "github.com/commitdev/zero/pkg/util/exit" - "github.com/commitdev/zero/pkg/util/flog" - yaml "gopkg.in/yaml.v2" -) - -var GetCredentialsPath = getCredentialsPath - -type ProjectCredentials map[string]ProjectCredential - -type ProjectCredential struct { - ProjectName string `yaml:"-"` - AWSResourceConfig `yaml:"aws,omitempty" vendor:"aws"` - GithubResourceConfig `yaml:"github,omitempty" vendor:"github"` - CircleCiResourceConfig `yaml:"circleci,omitempty" vendor:"circleci"` -} - -type AWSResourceConfig struct { - AccessKeyID string `yaml:"accessKeyId,omitempty" env:"AWS_ACCESS_KEY_ID,omitempty"` - SecretAccessKey string `yaml:"secretAccessKey,omitempty" env:"AWS_SECRET_ACCESS_KEY,omitempty"` -} -type GithubResourceConfig struct { - AccessToken string `yaml:"accessToken,omitempty" env:"GITHUB_ACCESS_TOKEN,omitempty"` -} -type CircleCiResourceConfig struct { - ApiKey string `yaml:"apiKey,omitempty" env:"CIRCLECI_API_KEY,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 -} - -// AsEnvVars marshals ProjectCredential as a map of key/value strings suitable for environment variables -func (p ProjectCredential) AsEnvVars() map[string]string { - t := reflect.ValueOf(p) - - list := make(map[string]string) - list = gatherFieldTags(t, list) - - return list -} - -func gatherFieldTags(t reflect.Value, list map[string]string) map[string]string { - reflectType := t.Type() - - for i := 0; i < t.NumField(); i++ { - fieldValue := t.Field(i) - fieldType := reflectType.Field(i) - - if fieldType.Type.Kind() == reflect.Struct { - list = gatherFieldTags(fieldValue, list) - continue - } - - if env := fieldType.Tag.Get("env"); env != "" { - name, opts := parseTag(env) - if idx := strings.Index(opts, "omitempty"); idx != -1 && fieldValue.String() == "" { - continue - } - list[name] = fieldValue.String() - } - } - return list -} - -func (p ProjectCredential) SelectedVendorsCredentialsAsEnv(vendors []string) map[string]string { - t := reflect.ValueOf(p) - envs := map[string]string{} - for i := 0; i < t.NumField(); i++ { - childStruct := t.Type().Field(i) - childValue := t.Field(i) - if tag := childStruct.Tag.Get("vendor"); tag != "" && util.ItemInSlice(vendors, tag) { - envs = gatherFieldTags(childValue, envs) - } - } - return envs -} - -func parseTag(tag string) (string, string) { - if idx := strings.Index(tag, ","); idx != -1 { - return tag[:idx], tag[idx+1:] - } - return tag, "" -} - -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, constants.ZeroHomeDirectory) - os.MkdirAll(rootDir, os.ModePerm) - filePath := path.Join(rootDir, constants.UserCredentialsYml) - 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) - } - flog.Debugf("Created credentials file: %s", credPath) - defer file.Close() - } - data, err := ioutil.ReadFile(credPath) - if err != nil { - exit.Fatal("Failed to read credentials file: %v", err) - } - flog.Debugf("Loaded credentials file: %s", credPath) - return data -} - -func GetProjectCredentials(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 - flog.Debugf("Saved project credentials : %s", project.ProjectName) - 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/globalconfig/global_config_test.go b/internal/config/globalconfig/global_config_test.go deleted file mode 100644 index eddfc8291..000000000 --- a/internal/config/globalconfig/global_config_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package globalconfig_test - -import ( - "fmt" - "io/ioutil" - "log" - "os" - "path" - "testing" - - "github.com/commitdev/zero/internal/config/globalconfig" - "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) { - globalconfig.GetCredentialsPath = func() string { - return path.Join(baseTestFixturesDir, "does-not-exist.yml") - } - credPath := globalconfig.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 - globalconfig.GetProjectCredentials("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() - globalconfig.GetCredentialsPath, teardownFn = testCredentialFile() - defer teardownFn() - - t.Run("Fixture file should have existing project with creds", func(t *testing.T) { - projectName := "my-project" - project := globalconfig.GetProjectCredentials(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 := globalconfig.GetProjectCredentials(projectName) - assert.Equal(t, "654", project.GithubResourceConfig.AccessToken) - }) -} - -func TestEditUserCredentials(t *testing.T) { - var teardownFn func() - globalconfig.GetCredentialsPath, teardownFn = testCredentialFile() - defer teardownFn() - - t.Run("Should create new project if not exist", func(t *testing.T) { - projectName := "test-project3" - project := globalconfig.GetProjectCredentials(projectName) - project.AWSResourceConfig.AccessKeyID = "TEST_KEY_ID_1" - globalconfig.Save(project) - newKeyID := globalconfig.GetProjectCredentials(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 := globalconfig.GetProjectCredentials(projectName) - project.AWSResourceConfig.AccessKeyID = "EDITED_ACCESS_KEY_ID" - globalconfig.Save(project) - newKeyID := globalconfig.GetProjectCredentials(projectName).AWSResourceConfig.AccessKeyID - assert.Equal(t, "EDITED_ACCESS_KEY_ID", newKeyID) - }) -} - -func TestMarshalProjectCredentialAsEnvVars(t *testing.T) { - t.Run("Should be able to marshal a ProjectCredential into env vars", func(t *testing.T) { - pc := globalconfig.ProjectCredential{ - AWSResourceConfig: globalconfig.AWSResourceConfig{ - AccessKeyID: "AKID", - SecretAccessKey: "SAK", - }, - CircleCiResourceConfig: globalconfig.CircleCiResourceConfig{ - ApiKey: "APIKEY", - }, - } - - envVars := pc.AsEnvVars() - assert.Equal(t, "AKID", envVars["AWS_ACCESS_KEY_ID"]) - assert.Equal(t, "SAK", envVars["AWS_SECRET_ACCESS_KEY"]) - assert.Equal(t, "APIKEY", envVars["CIRCLECI_API_KEY"]) - }) - - t.Run("should honor omitempty and left out empty values", func(t *testing.T) { - pc := globalconfig.ProjectCredential{} - - envVars := pc.AsEnvVars() - assert.Equal(t, 0, len(envVars)) - }) -} - -func TestMarshalSelectedVendorsCredentialsAsEnv(t *testing.T) { - pc := globalconfig.ProjectCredential{ - AWSResourceConfig: globalconfig.AWSResourceConfig{ - AccessKeyID: "AKID", - SecretAccessKey: "SAK", - }, - GithubResourceConfig: globalconfig.GithubResourceConfig{ - AccessToken: "FOOBAR", - }, - CircleCiResourceConfig: globalconfig.CircleCiResourceConfig{ - ApiKey: "APIKEY", - }, - } - - t.Run("cherry pick credentials by vendor", func(t *testing.T) { - envs := pc.SelectedVendorsCredentialsAsEnv([]string{"aws", "github"}) - assert.Equal(t, "AKID", envs["AWS_ACCESS_KEY_ID"]) - assert.Equal(t, "SAK", envs["AWS_SECRET_ACCESS_KEY"]) - assert.Equal(t, "FOOBAR", envs["GITHUB_ACCESS_TOKEN"]) - }) - - t.Run("omits vendors not selected", func(t *testing.T) { - envs := pc.SelectedVendorsCredentialsAsEnv([]string{"github"}) - assert.Equal(t, "FOOBAR", envs["GITHUB_ACCESS_TOKEN"]) - - _, hasAWSKeyID := envs["AWS_ACCESS_KEY_ID"] - assert.Equal(t, false, hasAWSKeyID) - _, hasAWSSecretAccessKey := envs["AWS_SECRET_ACCESS_KEY"] - assert.Equal(t, false, hasAWSSecretAccessKey) - _, hasCircleCIKey := envs["CIRCLECI_API_KEY"] - assert.Equal(t, false, hasCircleCIKey) - }) - - t.Run("omits vendors not selected", func(t *testing.T) { - envs := pc.SelectedVendorsCredentialsAsEnv([]string{}) - assert.Equal(t, 0, len(envs)) - }) - -} diff --git a/internal/config/moduleconfig/module_config.go b/internal/config/moduleconfig/module_config.go index 296d7d1c3..d6ba8e406 100644 --- a/internal/config/moduleconfig/module_config.go +++ b/internal/config/moduleconfig/module_config.go @@ -9,6 +9,7 @@ import ( yaml "gopkg.in/yaml.v2" + "github.com/commitdev/zero/internal/config/projectconfig" "github.com/commitdev/zero/pkg/util/flog" "github.com/iancoleman/strcase" ) @@ -25,14 +26,18 @@ type ModuleConfig struct { } type Parameter struct { - Field string - Label string `yaml:"label,omitempty"` - Options []string `yaml:"options,omitempty"` - Execute string `yaml:"execute,omitempty"` - Value string `yaml:"value,omitempty"` - Default string `yaml:"default,omitempty"` - Info string `yaml:"info,omitempty"` - FieldValidation Validate `yaml:"fieldValidation,omitempty"` + Field string + Label string `yaml:"label,omitempty"` + Options []string `yaml:"options,omitempty"` + Execute string `yaml:"execute,omitempty"` + Value string `yaml:"value,omitempty"` + Default string `yaml:"default,omitempty"` + Info string `yaml:"info,omitempty"` + FieldValidation Validate `yaml:"fieldValidation,omitempty"` + Type string `yaml:"type,omitempty"` + OmitFromProjectFile bool `yaml:"omitFromProjectFile,omitempty"` + Conditions []Condition `yaml:"conditions,omitempty"` + EnvVarName string `yaml:"envVarName,omitempty"` } type Condition struct { @@ -63,6 +68,21 @@ func (cfg ModuleConfig) collectMissing() []string { return missing } +// GetParamEnvVarTranslationMap returns a map for translating parameter's `Field` into env-var keys +// It loops through each parameter then adds to translation map if applicable +// for zero apply / zero init's prompt execute, +// this is useful for translating params like AWS credentials for running the AWS cli +func (cfg ModuleConfig) GetParamEnvVarTranslationMap() map[string]string { + translationMap := make(map[string]string) + for i := 0; i < len(cfg.Parameters); i++ { + param := cfg.Parameters[i] + if param.EnvVarName != "" { + translationMap[param.Field] = param.EnvVarName + } + } + return translationMap +} + func LoadModuleConfig(filePath string) (ModuleConfig, error) { config := ModuleConfig{} @@ -158,3 +178,39 @@ func findMissing(obj reflect.Value, path, metadata string, missing *[]string) { } } } + +// SummarizeParameters receives all parameters gathered from prompts during `Zero init` +// and based on module definition to construct the parameters for each module for zero-project.yml +// filters out parameters defined as OmitFromProjectFile: true +func SummarizeParameters(module ModuleConfig, allParams map[string]string) map[string]string { + moduleParams := make(projectconfig.Parameters) + // Loop through all the prompted values and find the ones relevant to this module + for parameterKey, parameterValue := range allParams { + for _, moduleParameter := range module.Parameters { + if moduleParameter.Field == parameterKey { + if moduleParameter.OmitFromProjectFile { + flog.Debugf("Omitted %s from %s", parameterKey, module.Name) + } else { + moduleParams[parameterKey] = parameterValue + } + } + } + } + return moduleParams +} + +// SummarizeConditions based on conditions from zero-module.yml +// creates and returns slice of conditions for project config +func SummarizeConditions(module ModuleConfig) []projectconfig.Condition { + moduleConditions := make([]projectconfig.Condition, len(module.Conditions)) + + for i, condition := range module.Conditions { + moduleConditions[i] = projectconfig.Condition{ + Action: condition.Action, + MatchField: condition.MatchField, + WhenValue: condition.WhenValue, + Data: condition.Data, + } + } + return moduleConditions +} diff --git a/internal/config/projectconfig/project_config.go b/internal/config/projectconfig/project_config.go index 445e90387..3300011d1 100644 --- a/internal/config/projectconfig/project_config.go +++ b/internal/config/projectconfig/project_config.go @@ -1,6 +1,7 @@ package projectconfig import ( + "errors" "io/ioutil" "log" @@ -29,6 +30,24 @@ type Module struct { Conditions []Condition `yaml:"conditions,omitempty"` } +// ReadVendorCredentialsFromModule uses parsed project-config's module +// based on vendor parameter, retrieve the vendor's credential +// for pre-defined functionalities (eg: Github api key for pushing repos to github) +func ReadVendorCredentialsFromModule(m Module, vendor string) (error, string) { + // this mapping could be useful for module config as well + vendorToParamMap := map[string]string{ + "github": "githubAccessToken", + "circleci": "circleciApiKey", + } + if parameterKey, ok := vendorToParamMap[vendor]; ok { + if val, ok := m.Parameters[parameterKey]; ok { + return nil, val + } + return errors.New("Parameter not found in module."), "" + } + return errors.New("Unsupported vendor provided."), "" +} + type Parameters map[string]string type Condition struct { diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 988474359..60368145c 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -1,13 +1,12 @@ package constants const ( - TemplatesDir = "tmp/templates" - ZeroProjectYml = "zero-project.yml" - ZeroModuleYml = "zero-module.yml" - UserCredentialsYml = "credentials.yml" - ZeroHomeDirectory = ".zero" - IgnoredPaths = "(?i)zero.module.yml|.git/" - TemplateExtn = ".tmpl" + TemplatesDir = "tmp/templates" + ZeroProjectYml = "zero-project.yml" + ZeroModuleYml = "zero-module.yml" + ZeroHomeDirectory = ".zero" + IgnoredPaths = "(?i)zero.module.yml|.git/" + TemplateExtn = ".tmpl" // prompt constants diff --git a/internal/init/custom-prompts.go b/internal/init/custom-prompts.go new file mode 100644 index 000000000..73a431152 --- /dev/null +++ b/internal/init/custom-prompts.go @@ -0,0 +1,50 @@ +package init + +import ( + "errors" + "fmt" + + "github.com/commitdev/zero/internal/config/moduleconfig" + project "github.com/commitdev/zero/pkg/credentials" +) + +// CustomPromptHandler handles non-input and enum options prompts +// zero-module's parameters allow prompts to specify types of custom actions +// this allows non-standard enum / string input to be added, such as AWS profile picker +func CustomPromptHandler(promptType string, params map[string]string) error { + switch promptType { + + case "AWSProfilePicker": + err := promptAWSProfilePicker(params) + if err != nil { + params["useExistingAwsProfile"] = "no" + return err + } + default: + return errors.New(fmt.Sprintf("Unsupported custom prompt type %s.", promptType)) + } + return nil +} + +func promptAWSProfilePicker(params map[string]string) error { + profiles, err := project.GetAWSProfiles() + if err != nil { + return err + } + + awsPrompt := PromptHandler{ + Parameter: moduleconfig.Parameter{ + Field: "aws_profile", + Label: "Select AWS Profile", + Options: profiles, + }, + Condition: NoCondition, + Validate: NoValidation, + } + _, value := promptParameter(awsPrompt) + credErr := project.FillAWSProfile("", value, params) + if credErr != nil { + return errors.New("Failed to retrieve profile, falling back to User input") + } + return nil +} diff --git a/internal/init/init.go b/internal/init/init.go index 1f273dff7..a6bcf227d 100644 --- a/internal/init/init.go +++ b/internal/init/init.go @@ -6,13 +6,10 @@ import ( "path" "sync" - "github.com/commitdev/zero/internal/config/globalconfig" "github.com/commitdev/zero/internal/config/moduleconfig" "github.com/commitdev/zero/internal/config/projectconfig" "github.com/commitdev/zero/internal/module" "github.com/commitdev/zero/internal/registry" - "github.com/commitdev/zero/internal/util" - project "github.com/commitdev/zero/pkg/credentials" "github.com/commitdev/zero/pkg/util/exit" "github.com/commitdev/zero/pkg/util/flog" "github.com/manifoldco/promptui" @@ -22,7 +19,11 @@ import ( func Init(outDir string, localModulePath string) *projectconfig.ZeroProjectConfig { projectConfig := defaultProjConfig() - projectConfig.Name = getProjectNamePrompt().GetParam(projectConfig.Parameters) + projectRootParams := map[string]string{} + emptyEnvVarTranslationMap := map[string]string{} + promptName := getProjectNamePrompt() + promptName.RunPrompt(projectRootParams, emptyEnvVarTranslationMap) + projectConfig.Name = projectRootParams[promptName.Field] rootDir := path.Join(outDir, projectConfig.Name) flog.Infof(":tada: Initializing project") @@ -41,44 +42,23 @@ func Init(outDir string, localModulePath string) *projectconfig.ZeroProjectConfi initParams := make(map[string]string) projectConfig.ShouldPushRepositories = true - initParams["ShouldPushRepositories"] = prompts["ShouldPushRepositories"].GetParam(initParams) + prompts["ShouldPushRepositories"].RunPrompt(initParams, emptyEnvVarTranslationMap) if initParams["ShouldPushRepositories"] == "n" { projectConfig.ShouldPushRepositories = false } // Prompting for push-up stream, then conditionally prompting for github - initParams["GithubRootOrg"] = prompts["GithubRootOrg"].GetParam(initParams) - projectCredentials := globalconfig.GetProjectCredentials(projectConfig.Name) - credentialPrompts := getCredentialPrompts(projectCredentials, moduleConfigs) - projectCredentials = promptCredentialsAndFillProjectCreds(credentialPrompts, projectCredentials) - globalconfig.Save(projectCredentials) - projectParameters := promptAllModules(moduleConfigs, projectCredentials) + prompts["GithubRootOrg"].RunPrompt(initParams, emptyEnvVarTranslationMap) + + projectData := promptAllModules(moduleConfigs) // Map parameter values back to specific modules for moduleName, module := range moduleConfigs { - repoName := prompts[moduleName].GetParam(initParams) + prompts[moduleName].RunPrompt(initParams, emptyEnvVarTranslationMap) + repoName := initParams[prompts[moduleName].Field] repoURL := fmt.Sprintf("%s/%s", initParams["GithubRootOrg"], repoName) - projectModuleParams := make(projectconfig.Parameters) - projectModuleConditions := []projectconfig.Condition{} - - // Loop through all the prompted values and find the ones relevant to this module - for parameterKey, parameterValue := range projectParameters { - for _, moduleParameter := range module.Parameters { - if moduleParameter.Field == parameterKey { - projectModuleParams[parameterKey] = parameterValue - } - } - } - - for _, condition := range module.Conditions { - newCond := projectconfig.Condition{ - Action: condition.Action, - MatchField: condition.MatchField, - WhenValue: condition.WhenValue, - Data: condition.Data, - } - projectModuleConditions = append(projectModuleConditions, newCond) - } + projectModuleParams := moduleconfig.SummarizeParameters(module, projectData) + projectModuleConditions := moduleconfig.SummarizeConditions(module) projectConfig.Modules[moduleName] = projectconfig.NewModule( projectModuleParams, @@ -90,9 +70,6 @@ func Init(outDir string, localModulePath string) *projectconfig.ZeroProjectConfi ) } - // TODO: load ~/.zero/config.yml (or credentials) - // TODO: prompt global credentials - return &projectConfig } @@ -119,20 +96,6 @@ func loadAllModules(moduleSources []string) (map[string]moduleconfig.ModuleConfi return modules, mappedSources } -// promptAllModules takes a map of all the modules and prompts the user for values for all the parameters -func promptAllModules(modules map[string]moduleconfig.ModuleConfig, projectCredentials globalconfig.ProjectCredential) map[string]string { - parameterValues := map[string]string{"projectName": projectCredentials.ProjectName} - for _, config := range modules { - var err error - - parameterValues, err = PromptModuleParams(config, parameterValues, projectCredentials) - if err != nil { - exit.Fatal("Exiting prompt: %v\n", err) - } - } - return parameterValues -} - // Project name is prompt individually because the rest of the prompts // requires the projectName to populate defaults func getProjectNamePrompt() PromptHandler { @@ -186,123 +149,6 @@ func getProjectPrompts(projectName string, modules map[string]moduleconfig.Modul return handlers } -func getCredentialPrompts(projectCredentials globalconfig.ProjectCredential, moduleConfigs map[string]moduleconfig.ModuleConfig) []CredentialPrompts { - var uniqueVendors []string - for _, module := range moduleConfigs { - uniqueVendors = appendToSet(uniqueVendors, module.RequiredCredentials) - } - - // map is to keep track of which vendor they belong to, to fill them back into the projectConfig - prompts := []CredentialPrompts{} - for _, vendor := range AvailableVendorOrders { - if util.ItemInSlice(uniqueVendors, vendor) { - vendorPrompts := CredentialPrompts{vendor, mapVendorToPrompts(projectCredentials, vendor)} - prompts = append(prompts, vendorPrompts) - } - } - return prompts -} - -func mapVendorToPrompts(projectCred globalconfig.ProjectCredential, vendor string) []PromptHandler { - var prompts []PromptHandler - profiles, err := project.GetAWSProfiles() - if err != nil { - profiles = []string{} - } - - // if no profiles available, dont prompt use to pick profile - customAwsPickProfileCondition := func(param map[string]string) bool { - if len(profiles) == 0 { - flog.Infof(":warning: No AWS profiles found, please manually input AWS credentials") - return false - } else { - return true - } - } - - // condition for prompting manual AWS credentials input - customAwsMustInputCondition := func(param map[string]string) bool { - toPickProfile := awsPickProfile - if val, ok := param["use_aws_profile"]; ok && val != toPickProfile { - return true - } - return false - } - - switch vendor { - case "aws": - awsPrompts := []PromptHandler{ - { - Parameter: moduleconfig.Parameter{ - Field: "use_aws_profile", - Label: "Use credentials from existing AWS profiles?", - Options: []string{awsPickProfile, awsManualInputCredentials}, - }, - Condition: customAwsPickProfileCondition, - Validate: NoValidation, - }, - { - Parameter: moduleconfig.Parameter{ - Field: "aws_profile", - Label: "Select AWS Profile", - Options: profiles, - }, - Condition: KeyMatchCondition("use_aws_profile", awsPickProfile), - Validate: NoValidation, - }, - { - Parameter: moduleconfig.Parameter{ - Field: "accessKeyId", - Label: "AWS Access Key ID", - Default: projectCred.AWSResourceConfig.AccessKeyID, - Info: `AWS Access Key ID/Secret: used for provisioning infrastructure in AWS -The token can be generated at https://console.aws.amazon.com/iam/home?#/security_credentials`, - }, - Condition: CustomCondition(customAwsMustInputCondition), - Validate: ValidateAKID, - }, - { - Parameter: moduleconfig.Parameter{ - Field: "secretAccessKey", - Label: "AWS Secret access key", - Default: projectCred.AWSResourceConfig.SecretAccessKey, - }, - Condition: CustomCondition(customAwsMustInputCondition), - Validate: ValidateSAK, - }, - } - prompts = append(prompts, awsPrompts...) - case "github": - githubPrompt := PromptHandler{ - Parameter: moduleconfig.Parameter{ - Field: "accessToken", - Label: "Github Personal Access Token with access to the above organization", - Default: projectCred.GithubResourceConfig.AccessToken, - Info: `Github personal access token: used for creating repositories for your project -Requires the following permissions: [repo::public_repo, admin::orgread:org] -The token can be created at https://github.com/settings/tokens`, - }, - Condition: NoCondition, - Validate: NoValidation, - } - prompts = append(prompts, githubPrompt) - case "circleci": - circleCiPrompt := PromptHandler{ - Parameter: moduleconfig.Parameter{ - Field: "apiKey", - Label: "Circleci api key for CI/CD", - Default: projectCred.CircleCiResourceConfig.ApiKey, - Info: `CircleCI api token: used for setting up CI/CD for your project -The token can be created at https://app.circleci.com/settings/user/tokens`, - }, - Condition: NoCondition, - Validate: NoValidation, - } - prompts = append(prompts, circleCiPrompt) - } - return prompts -} - func chooseCloudProvider(projectConfig *projectconfig.ZeroProjectConfig) { // @TODO move options into configs providerPrompt := promptui.Select{ diff --git a/internal/init/prompts.go b/internal/init/prompts.go index 018375c87..b02752b19 100644 --- a/internal/init/prompts.go +++ b/internal/init/prompts.go @@ -9,15 +9,12 @@ import ( "regexp" "strings" - "github.com/commitdev/zero/internal/config/globalconfig" "github.com/commitdev/zero/internal/config/moduleconfig" "github.com/commitdev/zero/internal/constants" "github.com/commitdev/zero/internal/util" - "github.com/commitdev/zero/pkg/credentials" "github.com/commitdev/zero/pkg/util/exit" "github.com/commitdev/zero/pkg/util/flog" "github.com/manifoldco/promptui" - "gopkg.in/yaml.v2" ) // Constant to maintain prompt orders so users can have the same flow, @@ -103,7 +100,15 @@ func ValidateProjectName(input string) error { return nil } -func (p PromptHandler) GetParam(projectParams map[string]string) string { +// RunPrompt obtains the value of PromptHandler depending on the parameter's definition +// for the project config, there are multiple ways of obtaining the value +// values go into params depending on `Condition` as the highest precedence (Whether it gets this value) +// then follows this order to determine HOW it obtains that value +// 1. Execute (this could potentially be refactored into type + data) +// 2. type: specific ways of obtaining values (in AWS credential case it will set 2 values to the map) +// 3. value: directly assigns a value to a parameter +// 4. prompt: requires users to select an option OR input a string +func (p PromptHandler) RunPrompt(projectParams map[string]string, envVarTranslationMap map[string]string) error { var err error var result string @@ -116,19 +121,24 @@ func (p PromptHandler) GetParam(projectParams map[string]string) string { // 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) + result = executeCmd(p.Parameter.Execute, projectParams, envVarTranslationMap) + } else if p.Parameter.Type != "" { + err = CustomPromptHandler(p.Parameter.Type, projectParams) } else if p.Parameter.Value != "" { result = p.Parameter.Value } else { err, result = promptParameter(p) } if err != nil { - exit.Fatal("Exiting prompt: %v\n", err) + return err } - return sanitizeParameterValue(result) + // Append the result to parameter map + projectParams[p.Field] = sanitizeParameterValue(result) + } else { + flog.Debugf("Skipping prompt(%s) due to condition failed", p.Field) } - return "" + return nil } func promptParameter(prompt PromptHandler) (error, string) { @@ -164,9 +174,11 @@ func promptParameter(prompt PromptHandler) (error, string) { return nil, result } -func executeCmd(command string, envVars map[string]string) string { +func executeCmd(command string, envVars map[string]string, envVarTranslationMap map[string]string) string { cmd := exec.Command("bash", "-c", command) - cmd.Env = util.AppendProjectEnvToCmdEnv(envVars, os.Environ()) + // Might need to pass down module's translation map as well, + // currently only works in `zero apply` + cmd.Env = util.AppendProjectEnvToCmdEnv(envVars, os.Environ(), envVarTranslationMap) out, err := cmd.Output() flog.Debugf("Running command: %s", command) if err != nil { @@ -182,23 +194,23 @@ func sanitizeParameterValue(str string) string { return re.ReplaceAllString(str, "") } -// PromptParams renders series of prompt UI based on the config -func PromptModuleParams(moduleConfig moduleconfig.ModuleConfig, parameters map[string]string, projectCredentials globalconfig.ProjectCredential) (map[string]string, error) { - credentialEnvs := projectCredentials.SelectedVendorsCredentialsAsEnv(moduleConfig.RequiredCredentials) - for _, promptConfig := range moduleConfig.Parameters { +// PromptModuleParams renders series of prompt UI based on the config +func PromptModuleParams(moduleConfig moduleconfig.ModuleConfig, parameters map[string]string) (map[string]string, error) { + envVarTranslationMap := moduleConfig.GetParamEnvVarTranslationMap() + for _, parameter := range moduleConfig.Parameters { // deduplicate fields already prompted and received - if _, isAlreadySet := parameters[promptConfig.Field]; isAlreadySet { + if _, isAlreadySet := parameters[parameter.Field]; isAlreadySet { continue } var validateFunc func(input string) error = nil // type:regex field validation for zero-module.yaml - if promptConfig.FieldValidation.Type == constants.RegexValidation { + if parameter.FieldValidation.Type == constants.RegexValidation { validateFunc = func(input string) error { - var regexRule = regexp.MustCompile(promptConfig.FieldValidation.Value) + var regexRule = regexp.MustCompile(parameter.FieldValidation.Value) if !regexRule.MatchString(input) { - return errors.New(promptConfig.FieldValidation.ErrorMessage) + return errors.New(parameter.FieldValidation.ErrorMessage) } return nil } @@ -206,49 +218,65 @@ func PromptModuleParams(moduleConfig moduleconfig.ModuleConfig, parameters map[s // TODO: type:fuction field validation for zero-module.yaml promptHandler := PromptHandler{ - Parameter: promptConfig, - Condition: NoCondition, + Parameter: parameter, + Condition: paramConditionsMapper(parameter.Conditions), Validate: validateFunc, } // merging the context of param and credentals // this treats credentialEnvs as throwaway, parameters is shared between modules // so credentials should not be in parameters as it gets returned to parent - for k, v := range parameters { - credentialEnvs[k] = v + // for k, v := range parameters { + // credentialEnvs[k] = v + // } + err := promptHandler.RunPrompt(parameters, envVarTranslationMap) + if err != nil { + return parameters, err } - result := promptHandler.GetParam(credentialEnvs) - - parameters[promptConfig.Field] = result } + flog.Debugf("Module %s prompt: \n %#v", moduleConfig.Name, parameters) return parameters, nil } -func promptCredentialsAndFillProjectCreds(credentialPrompts []CredentialPrompts, creds globalconfig.ProjectCredential) globalconfig.ProjectCredential { - promptsValues := map[string]map[string]string{} +// promptAllModules takes a map of all the modules and prompts the user for values for all the parameters +// Important: This is done here because in this step we share the parameter across modules, +// meaning if module A and B both asks for region, it will reuse the response for both (and is deduped during runtime) +func promptAllModules(modules map[string]moduleconfig.ModuleConfig) map[string]string { + parameterValues := map[string]string{} + for _, config := range modules { + var err error - for _, prompts := range credentialPrompts { - vendor := prompts.Vendor - vendorPromptValues := map[string]string{} - - // vendors like AWS have multiple prompts (accessKeyId and secretAccessKey) - for _, prompt := range prompts.Prompts { - vendorPromptValues[prompt.Field] = prompt.GetParam(vendorPromptValues) + parameterValues, err = PromptModuleParams(config, parameterValues) + if err != nil { + exit.Fatal("Exiting prompt(%s): %v\n", config.Name, err) } - promptsValues[vendor] = vendorPromptValues } + return parameterValues +} - // FIXME: what is a good way to dynamically modify partial data of a struct - // current just marashing to yaml, then unmarshaling into the base struct - yamlContent, _ := yaml.Marshal(promptsValues) - yaml.Unmarshal(yamlContent, &creds) - - // Fill AWS credentials based on profile from ~/.aws/credentials - if val, ok := promptsValues["aws"]; ok { - if val["use_aws_profile"] == awsPickProfile { - creds = credentials.GetAWSProfileProjectCredentials(val["aws_profile"], creds) +func paramConditionsMapper(conditions []moduleconfig.Condition) CustomConditionSignature { + if len(conditions) == 0 { + return NoCondition + } else { + return func(params map[string]string) bool { + // Prompts must pass every condition to proceed + for i := 0; i < len(conditions); i++ { + cond := conditions[i] + if !conditionHandler(cond)(params) { + flog.Debugf("Did not meet condition %v, expected %v to be %v", cond.Action, cond.MatchField, cond.WhenValue) + return false + } + } + return true } } - return creds +} +func conditionHandler(cond moduleconfig.Condition) CustomConditionSignature { + if cond.Action == "KeyMatchCondition" { + return KeyMatchCondition(cond.MatchField, cond.WhenValue) + } else { + flog.Errorf("Unsupported condition") + return nil + } } func appendToSet(set []string, toAppend []string) []string { diff --git a/internal/init/prompts_test.go b/internal/init/prompts_test.go index 35c971a8b..75fbe5f1b 100644 --- a/internal/init/prompts_test.go +++ b/internal/init/prompts_test.go @@ -12,6 +12,7 @@ import ( func TestGetParam(t *testing.T) { + envVarTranslationMap := map[string]string{} projectParams := map[string]string{} t.Run("Should execute params without prompt", func(t *testing.T) { param := moduleconfig.Parameter{ @@ -25,8 +26,8 @@ func TestGetParam(t *testing.T) { initPrompts.NoValidation, } - result := prompt.GetParam(projectParams) - assert.Equal(t, "my-acconut-id", result) + prompt.RunPrompt(projectParams, envVarTranslationMap) + assert.Equal(t, "my-acconut-id", projectParams[param.Field]) }) t.Run("executes with project context", func(t *testing.T) { @@ -41,10 +42,9 @@ func TestGetParam(t *testing.T) { initPrompts.NoValidation, } - result := prompt.GetParam(map[string]string{ - "INJECTEDENV": "SOME_ENV_VAR_VALUE", - }) - assert.Equal(t, "SOME_ENV_VAR_VALUE", result) + projectParams := map[string]string{"INJECTEDENV": "SOME_ENV_VAR_VALUE"} + prompt.RunPrompt(projectParams, envVarTranslationMap) + assert.Equal(t, "SOME_ENV_VAR_VALUE", projectParams[param.Field]) }) t.Run("Should return static value", func(t *testing.T) { @@ -59,8 +59,125 @@ func TestGetParam(t *testing.T) { initPrompts.NoValidation, } - result := prompt.GetParam(projectParams) - assert.Equal(t, "lorem-ipsum", result) + prompt.RunPrompt(projectParams, envVarTranslationMap) + assert.Equal(t, "lorem-ipsum", projectParams[param.Field]) }) + t.Run("Prompt value to retain existing params", func(t *testing.T) { + projectParams = map[string]string{ + "existing_value": "foo", + } + param := moduleconfig.Parameter{ + Field: "new_value", + Value: "bar", + } + + prompt := initPrompts.PromptHandler{ + param, + initPrompts.NoCondition, + initPrompts.NoValidation, + } + + prompt.RunPrompt(projectParams, envVarTranslationMap) + assert.Equal(t, "foo", projectParams["existing_value"]) + assert.Equal(t, "bar", projectParams[param.Field]) + }) + + t.Run("Prompt to apply in order and allow EnvVarMapping", func(t *testing.T) { + + projectParams = map[string]string{} + params := []moduleconfig.Parameter{ + { + Field: "param1", + Value: "foo", + EnvVarName: "envvar1", + }, + { + Field: "param2", + Execute: "echo $envvar1 bar", + EnvVarName: "envvar2", + }, + { + Field: "param3", + Execute: "echo $envvar2 baz", + }, + } + module := moduleconfig.ModuleConfig{Parameters: params} + + projectParams, _ = initPrompts.PromptModuleParams(module, projectParams) + + assert.Equal(t, "foo", projectParams["param1"]) + assert.Equal(t, "foo bar", projectParams["param2"], "should reference param1 via env-var") + assert.Equal(t, "foo bar baz", projectParams["param3"], "should reference param2 via env-var") + }) + + t.Run("Prompt conditions", func(t *testing.T) { + + projectParams = map[string]string{} + params := []moduleconfig.Parameter{ + { + Field: "param1", + Value: "pass", + }, + { + Field: "passing_condition", + Value: "pass", + Conditions: []moduleconfig.Condition{ + { + Action: "KeyMatchCondition", + WhenValue: "pass", + MatchField: "param1", + }, + }, + }, + { + Field: "failing_condition", + Value: "pass", + Conditions: []moduleconfig.Condition{ + { + Action: "KeyMatchCondition", + WhenValue: "not foo", + MatchField: "param1", + }, + }, + }, + { + Field: "multiple_condition", + Value: "pass", + Conditions: []moduleconfig.Condition{ + { + Action: "KeyMatchCondition", + WhenValue: "pass", + MatchField: "param1", + }, + { + Action: "KeyMatchCondition", + WhenValue: "pass", + MatchField: "passing_condition", + }, + }, + }, + } + module := moduleconfig.ModuleConfig{Parameters: params} + projectParams, _ = initPrompts.PromptModuleParams(module, projectParams) + + assert.Equal(t, "pass", projectParams["param1"], "Value just hardcoded") + assert.Equal(t, "pass", projectParams["passing_condition"], "Expected to pass condition and set value") + assert.NotContains(t, projectParams, "failing_condition", "Expected to fail condition and not set value") + assert.Equal(t, "pass", projectParams["multiple_condition"], "Expected to pass multiple condition and set value") + }) + + t.Run("Should return error upon unsupported custom prompt type", func(t *testing.T) { + + projectParams = map[string]string{} + params := []moduleconfig.Parameter{ + { + Field: "param1", + Type: "random-type", + }, + } + module := moduleconfig.ModuleConfig{Parameters: params} + _, err := initPrompts.PromptModuleParams(module, projectParams) + assert.Equal(t, "Unsupported custom prompt type random-type.", err.Error()) + }) } diff --git a/internal/module/module_test.go b/internal/module/module_test.go index 1bf553f58..f0f55a50a 100644 --- a/internal/module/module_test.go +++ b/internal/module/module_test.go @@ -44,10 +44,39 @@ func TestParseModuleConfig(t *testing.T) { } assert.Equal(t, "platform", param.Field) assert.Equal(t, "CI Platform", param.Label) + + }) + + t.Run("OmitFromProjectFile default", func(t *testing.T) { + param, err := findParameter(mod.Parameters, "platform") + if err != nil { + panic(err) + } + assert.Equal(t, false, param.OmitFromProjectFile, "OmitFromProjectFile should default to false") + useCredsParam, useCredsErr := findParameter(mod.Parameters, "useExistingAwsProfile") + if useCredsErr != nil { + panic(useCredsErr) + } + assert.Equal(t, true, useCredsParam.OmitFromProjectFile, "OmitFromProjectFile should be read from file") }) - t.Run("requiredCredentials are loaded", func(t *testing.T) { - assert.Equal(t, []string{"aws", "circleci", "github"}, mod.RequiredCredentials) + t.Run("Parsing Conditions and Typed prompts from config", func(t *testing.T) { + param, err := findParameter(mod.Parameters, "profilePicker") + if err != nil { + panic(err) + } + assert.Equal(t, "AWSProfilePicker", param.Type) + assert.Equal(t, "KeyMatchCondition", param.Conditions[0].Action) + assert.Equal(t, "useExistingAwsProfile", param.Conditions[0].MatchField) + assert.Equal(t, "yes", param.Conditions[0].WhenValue) + }) + + t.Run("parsing envVarName from module config", func(t *testing.T) { + param, err := findParameter(mod.Parameters, "accessKeyId") + if err != nil { + panic(err) + } + assert.Equal(t, "AWS_ACCESS_KEY_ID", param.EnvVarName) }) t.Run("TemplateConfig is unmarshaled", func(t *testing.T) { diff --git a/internal/util/util.go b/internal/util/util.go index 3c22e996a..8dc5826c4 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -10,6 +10,7 @@ import ( "os/exec" "path" "path/filepath" + "reflect" "strconv" "strings" "text/template" @@ -113,11 +114,16 @@ func ExecuteCommandOutput(cmd *exec.Cmd, pathPrefix string, envars []string) str return string(out) } -// AppendProjectEnvToCmdEnv will add all the keys and values from envMap -// into envList as key-value pair strings (e.g.: "key=value") -func AppendProjectEnvToCmdEnv(envMap map[string]string, envList []string) []string { +// AppendProjectEnvToCmdEnv converts a key-value pair map into a slice of `key=value`s +// allow module definition to use an alternative env-var-name than field while apply +func AppendProjectEnvToCmdEnv(envMap map[string]string, envList []string, translationMap map[string]string) []string { + for key, val := range envMap { if val != "" { + // overwrite key if exist in translation map + if val, ok := translationMap[key]; ok { + key = val + } envList = append(envList, fmt.Sprintf("%s=%s", key, val)) } } @@ -142,3 +148,34 @@ func ItemInSlice(slice []string, target string) bool { } return false } + +// ReflectStructValueIntoMap receives a resource of struct type as +// type AWSCreds struct{ +// AccessKeyID string `yaml:"accessKeyId,omitempty"` +// SecretAccessKey string `yaml:"secretAccessKey,omitempty"` +// }{ +// AccessKeyID: "FOO", +// SecretAccessKey: "BAR", +// } +// It will base on the tag, fill in the value to supplied map[string]string +func ReflectStructValueIntoMap(resource interface{}, tagName string, paramsToFill map[string]string) { + t := reflect.ValueOf(resource) + + for i := 0; i < t.NumField(); i++ { + + childStruct := t.Type().Field(i) + childValue := t.Field(i) + if childValue.Kind().String() != "string" { + continue + } + tag, _ := parseTag(childStruct.Tag.Get(tagName)) + paramsToFill[tag] = childValue.String() + } +} + +func parseTag(tag string) (string, string) { + if idx := strings.Index(tag, ","); idx != -1 { + return tag[:idx], tag[idx+1:] + } + return tag, "" +} diff --git a/pkg/credentials/credentials.go b/pkg/credentials/credentials.go index 2abe8b751..b58687519 100644 --- a/pkg/credentials/credentials.go +++ b/pkg/credentials/credentials.go @@ -8,10 +8,15 @@ import ( "regexp" "github.com/aws/aws-sdk-go/aws/credentials" - "github.com/commitdev/zero/internal/config/globalconfig" + "github.com/commitdev/zero/internal/util" ) -func AwsCredsPath() string { +type AWSResourceConfig struct { + AccessKeyID string `key:"accessKeyId"` + SecretAccessKey string `key:"secretAccessKey"` +} + +func awsCredsPath() string { usr, err := user.Current() if err != nil { log.Fatal(err) @@ -19,19 +24,31 @@ func AwsCredsPath() string { return filepath.Join(usr.HomeDir, ".aws/credentials") } -func GetAWSProfileProjectCredentials(profileName string, creds globalconfig.ProjectCredential) globalconfig.ProjectCredential { - awsPath := AwsCredsPath() - return GetAWSProfileCredentials(awsPath, profileName, creds) +func fetchAWSConfig(awsPath string, profileName string) (error, AWSResourceConfig) { + + awsCreds, err := credentials.NewSharedCredentials(awsPath, profileName).Get() + if err != nil { + return err, AWSResourceConfig{} + } + return nil, AWSResourceConfig{ + AccessKeyID: awsCreds.AccessKeyID, + SecretAccessKey: awsCreds.SecretAccessKey, + } } -func GetAWSProfileCredentials(credsPath string, profileName string, creds globalconfig.ProjectCredential) globalconfig.ProjectCredential { - awsCreds, err := credentials.NewSharedCredentials(credsPath, profileName).Get() +// FillAWSProfile receives the AWS profile name, then parses +// the accessKeyId / secretAccessKey values into a map +func FillAWSProfile(pathToCredentialsFile string, profileName string, paramsToFill map[string]string) error { + if pathToCredentialsFile == "" { + pathToCredentialsFile = awsCredsPath() + } + + err, awsCreds := fetchAWSConfig(pathToCredentialsFile, profileName) if err != nil { - log.Fatal(err) + return err } - creds.AWSResourceConfig.AccessKeyID = awsCreds.AccessKeyID - creds.AWSResourceConfig.SecretAccessKey = awsCreds.SecretAccessKey - return creds + util.ReflectStructValueIntoMap(awsCreds, "key", paramsToFill) + return nil } // GetAWSProfiles returns a list of AWS forprofiles set up on the user's sytem diff --git a/pkg/credentials/credentials_test.go b/pkg/credentials/credentials_test.go index 9c9e3c62c..0fb70be26 100644 --- a/pkg/credentials/credentials_test.go +++ b/pkg/credentials/credentials_test.go @@ -3,24 +3,31 @@ package credentials_test import ( "testing" - "github.com/commitdev/zero/internal/config/globalconfig" "github.com/commitdev/zero/pkg/credentials" "github.com/stretchr/testify/assert" ) func TestFillAWSProfileCredentials(t *testing.T) { mockAwsCredentialFilePath := "../../tests/test_data/aws/mock_credentials.yml" + t.Run("fills project credentials", func(t *testing.T) { - projectCreds := globalconfig.ProjectCredential{} - projectCreds = credentials.GetAWSProfileCredentials(mockAwsCredentialFilePath, "default", projectCreds) - assert.Equal(t, "MOCK1_ACCESS_KEY", projectCreds.AWSResourceConfig.AccessKeyID) - assert.Equal(t, "MOCK1_SECRET_ACCESS_KEY", projectCreds.AWSResourceConfig.SecretAccessKey) + params := map[string]string{} + err := credentials.FillAWSProfile(mockAwsCredentialFilePath, "default", params) + if err != nil { + panic(err) + } + + assert.Equal(t, "MOCK1_ACCESS_KEY", params["accessKeyId"]) + assert.Equal(t, "MOCK1_SECRET_ACCESS_KEY", params["secretAccessKey"]) }) t.Run("supports non-default profiles", func(t *testing.T) { - projectCreds := globalconfig.ProjectCredential{} - projectCreds = credentials.GetAWSProfileCredentials(mockAwsCredentialFilePath, "foobar", projectCreds) - assert.Equal(t, "MOCK2_ACCESS_KEY", projectCreds.AWSResourceConfig.AccessKeyID) - assert.Equal(t, "MOCK2_SECRET_ACCESS_KEY", projectCreds.AWSResourceConfig.SecretAccessKey) + params := map[string]string{} + err := credentials.FillAWSProfile(mockAwsCredentialFilePath, "foobar", params) + if err != nil { + panic(err) + } + assert.Equal(t, "MOCK2_ACCESS_KEY", params["accessKeyId"]) + assert.Equal(t, "MOCK2_SECRET_ACCESS_KEY", params["secretAccessKey"]) }) } diff --git a/tests/test_data/apply/project1/Makefile b/tests/test_data/apply/project1/Makefile index 181788f40..78158f243 100644 --- a/tests/test_data/apply/project1/Makefile +++ b/tests/test_data/apply/project1/Makefile @@ -1,5 +1,6 @@ current_dir: @echo "foo: ${foo}" > project.out @echo "repo: ${REPOSITORY}" >> project.out + @echo "envVarName of viaEnvVarName: ${viaEnvVarName}" >> feature.out summary: diff --git a/tests/test_data/apply/project1/zero-module.yml b/tests/test_data/apply/project1/zero-module.yml index 5d6914f9b..e643090f5 100644 --- a/tests/test_data/apply/project1/zero-module.yml +++ b/tests/test_data/apply/project1/zero-module.yml @@ -17,3 +17,5 @@ requiredCredentials: parameters: - field: foo label: foo + - field: param1 + envVarName: viaEnvVarName diff --git a/tests/test_data/apply/zero-project.yml b/tests/test_data/apply/zero-project.yml index 09c239cfc..629a59b5f 100644 --- a/tests/test_data/apply/zero-project.yml +++ b/tests/test_data/apply/zero-project.yml @@ -4,6 +4,7 @@ modules: project1: parameters: foo: bar + param1: baz files: dir: project1 repo: github.com/commitdev/project1 diff --git a/tests/test_data/modules/ci/zero-module.yml b/tests/test_data/modules/ci/zero-module.yml index 872ebdff8..a9f0f38b7 100644 --- a/tests/test_data/modules/ci/zero-module.yml +++ b/tests/test_data/modules/ci/zero-module.yml @@ -31,3 +31,38 @@ parameters: options: - github - circlci + - field: circleci_api_key + label: "Circle CI API Key to setup your CI/CD for repositories" + conditions: + - action: KeyMatchCondition + matchField: platform + whenValue: "circlci" + - field: useExistingAwsProfile + label: "Use credentials from an existing AWS profile?" + options: + - "yes" + - "no" + omitFromProjectFile: yes + - field: profilePicker + omitFromProjectFile: yes + type: AWSProfilePicker + conditions: + - action: KeyMatchCondition + whenValue: "yes" + matchField: useExistingAwsProfile + - field: accessKeyId + label: AWS AccessKeyId + envVarName: "AWS_ACCESS_KEY_ID" + conditions: + - action: KeyMatchCondition + whenValue: "no" + matchField: useExistingAwsProfile + - field: secretAccessKey + envVarName: "AWS_SECRET_ACCESS_KEY" + label: AWS SecretAccessKey + conditions: + - action: KeyMatchCondition + whenValue: "no" + matchField: useExistingAwsProfile + - field: testExecute + execute: echo $AWS_ACCESS_KEY_ID