From a31b884d49c2ea211cb52e7dc46c5e82a1d11ad4 Mon Sep 17 00:00:00 2001 From: Joel Watson Date: Wed, 15 Mar 2023 13:38:18 -0500 Subject: [PATCH] Add support for monorepo-friendly structuring in doppler.yaml setup file --- doppler.yaml | 4 +- pkg/cmd/setup.go | 197 +++++++++++++++++-------------- pkg/controllers/repo_config.go | 23 ++-- pkg/models/repo_config.go | 21 +++- tests/e2e.sh | 1 + tests/e2e/setup.sh | 210 +++++++++++++++++++++++++++++++++ 6 files changed, 355 insertions(+), 101 deletions(-) create mode 100755 tests/e2e/setup.sh diff --git a/doppler.yaml b/doppler.yaml index 4863f836..a9f763c4 100644 --- a/doppler.yaml +++ b/doppler.yaml @@ -1,3 +1,3 @@ setup: - project: cli - config: dev + - project: cli + config: dev diff --git a/pkg/cmd/setup.go b/pkg/cmd/setup.go index de2c6967..0179e39b 100644 --- a/pkg/cmd/setup.go +++ b/pkg/cmd/setup.go @@ -18,6 +18,7 @@ package cmd import ( "errors" "fmt" + "path/filepath" "strings" "github.com/DopplerHQ/cli/pkg/configuration" @@ -63,106 +64,130 @@ func setup(cmd *cobra.Command, args []string) { utils.LogDebugError(err.Unwrap()) } - ignoreRepoConfig := - // ignore when repo config is blank - (repoConfig.Setup.Project == "" && repoConfig.Setup.Config == "") || - // ignore when project and config are already specified - (localConfig.EnclaveProject.Source == models.FlagSource.String() && localConfig.EnclaveConfig.Source == models.FlagSource.String()) - - // default to true so repo config is used on --no-interactive - useRepoConfig := true - if !ignoreRepoConfig && canPromptUser { - useRepoConfig = utils.ConfirmationPrompt("Use settings from repo config file (doppler.yaml)?", true) - } - - currentProject := localConfig.EnclaveProject.Value - selectedProject := "" - - switch localConfig.EnclaveProject.Source { - case models.FlagSource.String(): - selectedProject = localConfig.EnclaveProject.Value - case models.EnvironmentSource.String(): - utils.Log(valueFromEnvironmentNotice("DOPPLER_PROJECT")) - selectedProject = localConfig.EnclaveProject.Value - default: - if useRepoConfig && repoConfig.Setup.Project != "" { - utils.Print("Auto-selecting project from repo config file") - selectedProject = repoConfig.Setup.Project - break + // do an initial pass to check for duplicate paths. this is less efficient + // since we loop through the repos again next, but there should never be + // enough repos that this becomes a noticeable performance issue and it + // prevents us from partially setting things up when an issue exists. + pathCount := make(map[string]int) + for _, repo := range repoConfig.Setup { + pathCount[repo.Path] += 1 + if pathCount[repo.Path] > 1 { + utils.HandleError(errors.New("a path is being used more than once in the repo config file (doppler.yaml)")) } + } - projects, httpErr := http.GetProjects(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, 1, 100) - if !httpErr.IsNil() { - utils.HandleError(httpErr.Unwrap(), httpErr.Message) - } - if len(projects) == 0 { - utils.HandleError(errors.New("you do not have access to any projects")) + for _, repo := range repoConfig.Setup { + if len(repoConfig.Setup) > 1 && repo.Path == "" { + utils.HandleError(errors.New("a path must be specified for all repos when more than one exists in the repo config file (doppler.yaml)")) } - - defaultProject := scopedConfig.EnclaveProject.Value - if repoConfig.Setup.Project != "" { - defaultProject = repoConfig.Setup.Project + expandedPath, _ := filepath.Abs(repo.Path) + scopedConfig = configuration.Get(expandedPath) + + ignoreRepoConfig := + // ignore when repo config is blank + (repo.Project == "" && repo.Config == "") || + // ignore when project and config are already specified + (localConfig.EnclaveProject.Source == models.FlagSource.String() && localConfig.EnclaveConfig.Source == models.FlagSource.String()) + + // default to true so repo config is used on --no-interactive + useRepoConfig := true + if !ignoreRepoConfig && canPromptUser { + if len(repoConfig.Setup) > 1 && repo.Path != "" { + useRepoConfig = utils.ConfirmationPrompt(fmt.Sprintf("Use settings from repo config file (doppler.yaml) for %s?", expandedPath), true) + } else { + useRepoConfig = utils.ConfirmationPrompt("Use settings from repo config file (doppler.yaml)?", true) + } } - selectedProject = selectProject(projects, defaultProject, canPromptUser) - if selectedProject == "" { - utils.HandleError(errors.New("Invalid project")) - } - } + currentProject := localConfig.EnclaveProject.Value + selectedProject := "" - selectedConfiguredProject := selectedProject == currentProject - selectedConfig := "" - - switch localConfig.EnclaveConfig.Source { - case models.FlagSource.String(): - selectedConfig = localConfig.EnclaveConfig.Value - case models.EnvironmentSource.String(): - utils.Log(valueFromEnvironmentNotice("DOPPLER_CONFIG")) - selectedConfig = localConfig.EnclaveConfig.Value - default: - if useRepoConfig && repoConfig.Setup.Config != "" { - utils.Print("Auto-selecting config from repo config file") - selectedConfig = repoConfig.Setup.Config - break + switch localConfig.EnclaveProject.Source { + case models.FlagSource.String(): + selectedProject = localConfig.EnclaveProject.Value + case models.EnvironmentSource.String(): + utils.Log(valueFromEnvironmentNotice("DOPPLER_PROJECT")) + selectedProject = localConfig.EnclaveProject.Value + default: + if useRepoConfig && repo.Project != "" { + utils.Print("Auto-selecting project from repo config file") + selectedProject = repo.Project + break + } + + projects, httpErr := http.GetProjects(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, 1, 100) + if !httpErr.IsNil() { + utils.HandleError(httpErr.Unwrap(), httpErr.Message) + } + if len(projects) == 0 { + utils.HandleError(errors.New("you do not have access to any projects")) + } + + defaultProject := scopedConfig.EnclaveProject.Value + if repo.Project != "" { + defaultProject = repo.Project + } + + selectedProject = selectProject(projects, defaultProject, canPromptUser) + if selectedProject == "" { + utils.HandleError(errors.New("Invalid project")) + } } - configs, apiError := http.GetConfigs(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, selectedProject, "", 1, 100) - if !apiError.IsNil() { - utils.HandleError(apiError.Unwrap(), apiError.Message) - } - if len(configs) == 0 { - utils.Print("You project does not have any configs") - break - } + selectedConfiguredProject := selectedProject == currentProject + selectedConfig := "" - defaultConfig := scopedConfig.EnclaveConfig.Value - if repoConfig.Setup.Config != "" { - defaultConfig = repoConfig.Setup.Config + switch localConfig.EnclaveConfig.Source { + case models.FlagSource.String(): + selectedConfig = localConfig.EnclaveConfig.Value + case models.EnvironmentSource.String(): + utils.Log(valueFromEnvironmentNotice("DOPPLER_CONFIG")) + selectedConfig = localConfig.EnclaveConfig.Value + default: + if useRepoConfig && repo.Config != "" { + utils.Print("Auto-selecting config from repo config file") + selectedConfig = repo.Config + break + } + + configs, apiError := http.GetConfigs(localConfig.APIHost.Value, utils.GetBool(localConfig.VerifyTLS.Value, true), localConfig.Token.Value, selectedProject, "", 1, 100) + if !apiError.IsNil() { + utils.HandleError(apiError.Unwrap(), apiError.Message) + } + if len(configs) == 0 { + utils.Print("You project does not have any configs") + break + } + + defaultConfig := scopedConfig.EnclaveConfig.Value + if repo.Config != "" { + defaultConfig = repo.Config + } + + selectedConfig = selectConfig(configs, selectedConfiguredProject, defaultConfig, canPromptUser) + if selectedConfig == "" { + utils.HandleError(errors.New("Invalid config")) + } } - selectedConfig = selectConfig(configs, selectedConfiguredProject, defaultConfig, canPromptUser) - if selectedConfig == "" { - utils.HandleError(errors.New("Invalid config")) + configToSave := map[string]string{ + models.ConfigEnclaveProject.String(): selectedProject, + models.ConfigEnclaveConfig.String(): selectedConfig, } - } - - configToSave := map[string]string{ - models.ConfigEnclaveProject.String(): selectedProject, - models.ConfigEnclaveConfig.String(): selectedConfig, - } - if saveToken { - configToSave[models.ConfigToken.String()] = localConfig.Token.Value - } - configuration.Set(configuration.Scope, configToSave) - - if !utils.Silent { - // do not fetch the LocalConfig since we do not care about env variables or cmd flags - conf := configuration.Get(configuration.Scope) - valuesToPrint := []string{models.ConfigEnclaveConfig.String(), models.ConfigEnclaveProject.String()} if saveToken { - valuesToPrint = append(valuesToPrint, utils.RedactAuthToken(models.ConfigToken.String())) + configToSave[models.ConfigToken.String()] = localConfig.Token.Value + } + configuration.Set(expandedPath, configToSave) + + if !utils.Silent { + // do not fetch the LocalConfig since we do not care about env variables or cmd flags + conf := configuration.Get(expandedPath) + valuesToPrint := []string{models.ConfigEnclaveConfig.String(), models.ConfigEnclaveProject.String()} + if saveToken { + valuesToPrint = append(valuesToPrint, utils.RedactAuthToken(models.ConfigToken.String())) + } + printer.ScopedConfigValues(conf, valuesToPrint, models.ScopedOptionsMap(&conf), utils.OutputJSON, false, false) } - printer.ScopedConfigValues(conf, valuesToPrint, models.ScopedOptionsMap(&conf), utils.OutputJSON, false, false) } } diff --git a/pkg/controllers/repo_config.go b/pkg/controllers/repo_config.go index 1d96d3bd..f5954ced 100644 --- a/pkg/controllers/repo_config.go +++ b/pkg/controllers/repo_config.go @@ -32,7 +32,7 @@ const repoConfigFileName = "doppler.yaml" const ymlRepoConfigFileName = "doppler.yml" // RepoConfig Reads the configuration file (doppler.yaml) if exists and returns the set configuration -func RepoConfig() (models.RepoConfig, Error) { +func RepoConfig() (models.MultiRepoConfig, Error) { repoConfigFile := filepath.Join("./", repoConfigFileName) ymlRepoConfigFile := filepath.Join("./", ymlRepoConfigFileName) @@ -46,21 +46,28 @@ func RepoConfig() (models.RepoConfig, Error) { var e Error e.Err = err e.Message = "Unable to read doppler repo config file" - return models.RepoConfig{}, e + return models.MultiRepoConfig{}, e } - var repoConfig models.RepoConfig + var repoConfig models.MultiRepoConfig if err := yaml.Unmarshal(yamlFile, &repoConfig); err != nil { - var e Error - e.Err = err - e.Message = "Unable to parse doppler repo config file" - return models.RepoConfig{}, e + // Try parsing old repoConfig format (i.e., no slice) for backwards compatibility + var oldRepoConfig models.RepoConfig + if err := yaml.Unmarshal(yamlFile, &oldRepoConfig); err != nil { + var e Error + e.Err = err + e.Message = "Unable to parse doppler repo config file" + return models.MultiRepoConfig{}, e + } else { + repoConfig.Setup = append(repoConfig.Setup, oldRepoConfig.Setup) + return repoConfig, Error{} + } } return repoConfig, Error{} } else if utils.Exists(ymlRepoConfigFile) { utils.LogWarning(fmt.Sprintf("Found %s file, please rename to %s for repo configuration", ymlRepoConfigFile, repoConfigFileName)) } - return models.RepoConfig{}, Error{} + return models.MultiRepoConfig{}, Error{} } diff --git a/pkg/models/repo_config.go b/pkg/models/repo_config.go index daebbfdb..68ede074 100644 --- a/pkg/models/repo_config.go +++ b/pkg/models/repo_config.go @@ -16,10 +16,21 @@ limitations under the License. package models -// RepoConfig holds all repo configuration +// Config struct represents the basic project setup values +type ProjectConfig struct { + Config string `yaml:"config"` + Project string `yaml:"project"` + Path string `yaml:"path"` +} + +// RepoConfig struct representing legacy doppler.yaml setup file format +// that only supported a single project and config type RepoConfig struct { - Setup struct { - Config string `yaml:"config"` - Project string `yaml:"project"` - } `yaml:"setup"` + Setup ProjectConfig `yaml:"setup"` +} + +// MultiRepoConfig struct supports doppler.yaml files containing multiple +// project and config combos +type MultiRepoConfig struct { + Setup []ProjectConfig `yaml:"setup"` } diff --git a/tests/e2e.sh b/tests/e2e.sh index 352b1fc3..ba5e5e77 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -20,6 +20,7 @@ export DOPPLER_CONFIG="e2e" "$DIR/e2e/install-sh-update-in-place.sh" "$DIR/e2e/legacy-commands.sh" "$DIR/e2e/analytics.sh" +"$DIR/e2e/setup.sh" echo -e "\nAll tests completed successfully!" exit 0 diff --git a/tests/e2e/setup.sh b/tests/e2e/setup.sh new file mode 100755 index 00000000..6b3eed58 --- /dev/null +++ b/tests/e2e/setup.sh @@ -0,0 +1,210 @@ +#!/bin/bash + +set -euo pipefail + +TEST_NAME="setup file" +TEST_CONFIG_DIR="./temp-config-dir" +DOPPLER_PROJECT="" +DOPPLER_CONFIG="" + +cleanup() { + exit_code=$? + if [ "$exit_code" -ne 0 ]; then + echo "ERROR: '$TEST_NAME' tests failed during execution" + afterAll || echo "ERROR: Cleanup failed" + fi + + exit "$exit_code" +} +trap cleanup EXIT + +beforeAll() { + echo "INFO: Executing '$TEST_NAME' tests" + mv doppler.yaml doppler.yaml.bak +} + +beforeEach() { + header + rm -rf $TEST_CONFIG_DIR + rm -f doppler.yaml + cat << EOF > doppler.yaml +setup: + - project: cli + config: dev + path: . + - project: example + config: stg + path: example/ +EOF +} + +afterEach() { + footer +} + +afterAll() { + echo "INFO: Completed '$TEST_NAME' tests" + rm -rf $TEST_CONFIG_DIR + rm -f doppler.yaml + mv doppler.yaml.bak doppler.yaml +} + +header() { + echo "=========================================" + echo "EXECUTING: $name" +} + +footer() { + echo "=========================================" +} + +error() { + message=$1 + echo "$message" + exit 1 +} + +###################################################################### + +beforeAll + +###################################################################### +# + +name="test legacy doppler.yaml setup file" + +beforeEach + +# confirm that no projects or configs are set before loading the setup file +actual="$("$DOPPLER_BINARY" configure get project --plain --config-dir=$TEST_CONFIG_DIR)" +expected="" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected project at scope. expected '$expected', actual '$actual'" + +actual="$("$DOPPLER_BINARY" configure get config --plain --config-dir=$TEST_CONFIG_DIR)" +expected="" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected config at scope. expected '$expected', actual '$actual'" + +# test setup using legacy doppler.yaml +cat << EOF > doppler.yaml +setup: + project: cli + config: dev +EOF +actual="$("$DOPPLER_BINARY" setup --config-dir=$TEST_CONFIG_DIR --no-interactive)" +[[ "$actual" != "Unable to parse doppler repo config file" ]] || error "ERROR: setup file not parseable" + +# confirm correct projects and configs are setup for appropriate scopes +actual="$("$DOPPLER_BINARY" configure get project --plain --config-dir=$TEST_CONFIG_DIR)" +expected="cli" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected project at scope. expected '$expected', actual '$actual'" + +actual="$("$DOPPLER_BINARY" configure get config --plain --config-dir=$TEST_CONFIG_DIR)" +expected="dev" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected config at scope. expected '$expected', actual '$actual'" + +actual="$("$DOPPLER_BINARY" configure get project --plain --scope=./example --config-dir=$TEST_CONFIG_DIR)" +expected="cli" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected project at scope. expected '$expected', actual '$actual'" + +actual="$("$DOPPLER_BINARY" configure get config --plain --scope=./example --config-dir=$TEST_CONFIG_DIR)" +expected="dev" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected config at scope. expected '$expected', actual '$actual'" + +afterEach + +###################################################################### +# + +name="test doppler.yaml setup file with multiple projects & configs" + +beforeEach + +# confirm that no projects or configs are set before loading the setup file +actual="$("$DOPPLER_BINARY" configure get project --plain --config-dir=$TEST_CONFIG_DIR)" +expected="" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected project at scope. expected '$expected', actual '$actual'" + +actual="$("$DOPPLER_BINARY" configure get config --plain --config-dir=$TEST_CONFIG_DIR)" +expected="" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected config at scope. expected '$expected', actual '$actual'" + +actual="$("$DOPPLER_BINARY" configure get project --plain --scope=./example --config-dir=$TEST_CONFIG_DIR)" +expected="" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected project at scope. expected '$expected', actual '$actual'" + +actual="$("$DOPPLER_BINARY" configure get config --plain --scope=./example --config-dir=$TEST_CONFIG_DIR)" +expected="" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected config at scope. expected '$expected', actual '$actual'" + +# test setup using doppler.yaml with multiple projects and configs +actual="$("$DOPPLER_BINARY" setup --config-dir=$TEST_CONFIG_DIR --no-interactive)" +[[ $(echo "$actual" | grep -c "Auto-selecting project from repo config file") == "2" ]] || error "ERROR: unexpected number of project setups loaded" +[[ $(echo "$actual" | grep -c "Auto-selecting config from repo config file") == "2" ]] || error "ERROR: unexpected number of config setups loaded" +[[ "$actual" != "Unable to parse doppler repo config file" ]] || error "ERROR: setup file not parseable" + +# confirm correct projects and configs are setup for appropriate scopes +actual="$("$DOPPLER_BINARY" configure get project --plain --config-dir=$TEST_CONFIG_DIR)" +expected="cli" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected project at scope. expected '$expected', actual '$actual'" + +actual="$("$DOPPLER_BINARY" configure get config --plain --config-dir=$TEST_CONFIG_DIR)" +expected="dev" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected config at scope. expected '$expected', actual '$actual'" + +actual="$("$DOPPLER_BINARY" configure get project --plain --scope=./example --config-dir=$TEST_CONFIG_DIR)" +expected="example" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected project at scope. expected '$expected', actual '$actual'" + +actual="$("$DOPPLER_BINARY" configure get config --plain --scope=./example --config-dir=$TEST_CONFIG_DIR)" +expected="stg" +[[ "$actual" == "$expected" ]] || error "ERROR: unexpected config at scope. expected '$expected', actual '$actual'" + +afterEach + +###################################################################### + +name="ensure error displayed if multiple entries are specified without paths" + +beforeEach + +# test setup file with multiple entries that don't have paths specified +cat << EOF > doppler.yaml +setup: + - project: cli + config: dev + - project: example + config: dev +EOF +# we disable pipefail specifically inside the subshell since we expect this command to fail +actual="$(set +o pipefail; "$DOPPLER_BINARY" setup --config-dir=$TEST_CONFIG_DIR --no-interactive 2>&1 || true)" +expected="Doppler Error: a path must be specified for all repos when more than one exists in the repo config file (doppler.yaml)" +[[ "$actual" == *"$expected"* ]] || error "ERROR: setup not erroring when paths omitted for multiple entries. expected '$expected', actual '$actual'" + +afterEach + +###################################################################### + +name="ensure error displayed if multiple entries use the same path" + +beforeEach + +# test setup file with multiple entries that don't have paths specified +cat << EOF > doppler.yaml +setup: + - project: cli + config: dev + path: . + - project: example + config: dev + path: . +EOF +# we disable pipefail specifically inside the subshell since we expect this command to fail +actual="$(set +o pipefail; "$DOPPLER_BINARY" setup --config-dir=$TEST_CONFIG_DIR --no-interactive 2>&1 || true)" +expected="Doppler Error: a path is being used more than once in the repo config file (doppler.yaml)" +[[ "$actual" == *"$expected"* ]] || error "ERROR: setup not erroring when a path is used multiple times. expected '$expected', actual '$actual'" + +afterEach + +###################################################################### + +afterAll \ No newline at end of file