diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8cfed9749..ee6525b0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -232,6 +232,7 @@ jobs: # - demo-component-manifest - demo-component-versions - demo-context + - demo-configuration # - demo-custom-command # - demo-json-validation # - demo-opa-validation diff --git a/examples/demo-atmos-cli-imports/atmos.yaml b/examples/demo-atmos-cli-imports/atmos.yaml new file mode 100644 index 000000000..79b3ccd26 --- /dev/null +++ b/examples/demo-atmos-cli-imports/atmos.yaml @@ -0,0 +1,9 @@ +# Description: This is an example of a custom import configuration file. +# The import configuration file is used to load configurations from multiple files and directories. +# The configurations are merged together to create a single configuration object. +# The configurations are loaded in the order they are defined in the import section. +base_path: "./" +import: + - "https://raw.githubusercontent.com/cloudposse/atmos/refs/heads/main/atmos.yaml" # Load from a remote URL + - "configs.d/**/*" # Recursively load configurations from a directory + - "./logs.yaml" # Load a specific file diff --git a/examples/demo-atmos-cli-imports/configs.d/commands.yaml b/examples/demo-atmos-cli-imports/configs.d/commands.yaml new file mode 100644 index 000000000..8580ae9f0 --- /dev/null +++ b/examples/demo-atmos-cli-imports/configs.d/commands.yaml @@ -0,0 +1,7 @@ +# Custom CLI commands +commands: + - name: "test" + description: "Run all tests" + steps: + - atmos describe config + diff --git a/examples/demo-atmos-cli-imports/configs.d/tools/stack.yml b/examples/demo-atmos-cli-imports/configs.d/tools/stack.yml new file mode 100644 index 000000000..5b2994a33 --- /dev/null +++ b/examples/demo-atmos-cli-imports/configs.d/tools/stack.yml @@ -0,0 +1,7 @@ +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/_defaults.yaml" + name_pattern: "{dev}" diff --git a/examples/demo-atmos-cli-imports/configs.d/tools/terraform.yaml b/examples/demo-atmos-cli-imports/configs.d/tools/terraform.yaml new file mode 100644 index 000000000..adf9c72c2 --- /dev/null +++ b/examples/demo-atmos-cli-imports/configs.d/tools/terraform.yaml @@ -0,0 +1,7 @@ +components: + terraform: + base_path: "components/terraform" + apply_auto_approve: true + deploy_run_init: true + init_run_reconfigure: true + auto_generate_backend_file: false diff --git a/examples/demo-atmos-cli-imports/configs.d/vendor.yaml b/examples/demo-atmos-cli-imports/configs.d/vendor.yaml new file mode 100644 index 000000000..d6fe12e0c --- /dev/null +++ b/examples/demo-atmos-cli-imports/configs.d/vendor.yaml @@ -0,0 +1,9 @@ +vendor: + # Single file + base_path: "./vendor.yaml" + + # Directory with multiple files + #base_path: "./vendor" + + # Absolute path + #base_path: "vendor.d/vendor1.yaml" diff --git a/examples/demo-atmos-cli-imports/logs.yaml b/examples/demo-atmos-cli-imports/logs.yaml new file mode 100644 index 000000000..e389a26b8 --- /dev/null +++ b/examples/demo-atmos-cli-imports/logs.yaml @@ -0,0 +1,3 @@ +logs: + file: "/dev/stderr" + level: Debug diff --git a/examples/demo-configuration/atmos.d/commands.yaml b/examples/demo-configuration/atmos.d/commands.yaml new file mode 100644 index 000000000..c655ad569 --- /dev/null +++ b/examples/demo-configuration/atmos.d/commands.yaml @@ -0,0 +1,6 @@ +# Custom CLI commands +commands: +- name: "test" + description: "Run all tests with custom command" + steps: + - atmos describe config diff --git a/examples/demo-configuration/atmos.d/logs.yaml b/examples/demo-configuration/atmos.d/logs.yaml new file mode 100644 index 000000000..e389a26b8 --- /dev/null +++ b/examples/demo-configuration/atmos.d/logs.yaml @@ -0,0 +1,3 @@ +logs: + file: "/dev/stderr" + level: Debug diff --git a/examples/demo-configuration/atmos.d/tools/helmfile.yml b/examples/demo-configuration/atmos.d/tools/helmfile.yml new file mode 100644 index 000000000..78780b119 --- /dev/null +++ b/examples/demo-configuration/atmos.d/tools/helmfile.yml @@ -0,0 +1,17 @@ +components: + helmfile: + # Can also be set using 'ATMOS_COMPONENTS_HELMFILE_BASE_PATH' ENV var, or '--helmfile-dir' command-line argument + # Supports both absolute and relative paths + base_path: "components/helmfile" + # Can also be set using 'ATMOS_COMPONENTS_HELMFILE_USE_EKS' ENV var + # If not specified, defaults to 'true' + use_eks: true + # Can also be set using 'ATMOS_COMPONENTS_HELMFILE_KUBECONFIG_PATH' ENV var + kubeconfig_path: "/dev/shm" + # Can also be set using 'ATMOS_COMPONENTS_HELMFILE_HELM_AWS_PROFILE_PATTERN' ENV var + helm_aws_profile_pattern: "{namespace}-{tenant}-gbl-{stage}-helm" + # Can also be set using 'ATMOS_COMPONENTS_HELMFILE_CLUSTER_NAME_PATTERN' ENV var + cluster_name_pattern: "{namespace}-{tenant}-{environment}-{stage}-eks-cluster" + + + diff --git a/examples/demo-configuration/atmos.d/tools/stack.yaml b/examples/demo-configuration/atmos.d/tools/stack.yaml new file mode 100644 index 000000000..5b2994a33 --- /dev/null +++ b/examples/demo-configuration/atmos.d/tools/stack.yaml @@ -0,0 +1,7 @@ +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/_defaults.yaml" + name_pattern: "{dev}" diff --git a/examples/demo-configuration/atmos.d/tools/terraform.yaml b/examples/demo-configuration/atmos.d/tools/terraform.yaml new file mode 100644 index 000000000..201d2ff8a --- /dev/null +++ b/examples/demo-configuration/atmos.d/tools/terraform.yaml @@ -0,0 +1,23 @@ +components: + terraform: + # Optional `command` specifies the executable to be called by `atmos` when running Terraform commands + # If not defined, `terraform` is used + # Examples: + # command: terraform + # command: /usr/local/bin/terraform + # command: /usr/local/bin/terraform-1.8 + # command: tofu + # command: /usr/local/bin/tofu-1.7.1 + # Can also be set using 'ATMOS_COMPONENTS_TERRAFORM_COMMAND' ENV var, or '--terraform-command' command-line argument + command: terraform + # Can also be set using 'ATMOS_COMPONENTS_TERRAFORM_BASE_PATH' ENV var, or '--terraform-dir' command-line argument + # Supports both absolute and relative paths + base_path: "components/terraform" + # Can also be set using 'ATMOS_COMPONENTS_TERRAFORM_APPLY_AUTO_APPROVE' ENV var + apply_auto_approve: false + # Can also be set using 'ATMOS_COMPONENTS_TERRAFORM_DEPLOY_RUN_INIT' ENV var, or '--deploy-run-init' command-line argument + deploy_run_init: true + # Can also be set using 'ATMOS_COMPONENTS_TERRAFORM_INIT_RUN_RECONFIGURE' ENV var, or '--init-run-reconfigure' command-line argument + init_run_reconfigure: true + # Can also be set using 'ATMOS_COMPONENTS_TERRAFORM_AUTO_GENERATE_BACKEND_FILE' ENV var, or '--auto-generate-backend-file' command-line argument + auto_generate_backend_file: true diff --git a/examples/demo-configuration/atmos.yaml b/examples/demo-configuration/atmos.yaml new file mode 100644 index 000000000..063685dbc --- /dev/null +++ b/examples/demo-configuration/atmos.yaml @@ -0,0 +1,5 @@ +# Description: Configuration file for the Atmos CLI +# default import path is ./atmos.d import .yaml files from the directory and merge them +base_path: "./" + + diff --git a/examples/demo-env/atmos.yaml b/examples/demo-env/atmos.yaml new file mode 100644 index 000000000..555d07515 --- /dev/null +++ b/examples/demo-env/atmos.yaml @@ -0,0 +1,21 @@ +base_path: "./" + +components: + terraform: + base_path: "components/terraform" + apply_auto_approve: false + deploy_run_init: true + init_run_reconfigure: true + auto_generate_backend_file: false + +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/_defaults.yaml" + name_pattern: "{stage}" + +logs: + file: "/dev/stderr" + level: Info diff --git a/examples/demo-env/components/terraform/example/main.tf b/examples/demo-env/components/terraform/example/main.tf new file mode 100644 index 000000000..7c6ff8406 --- /dev/null +++ b/examples/demo-env/components/terraform/example/main.tf @@ -0,0 +1,4 @@ +# Fetch the required environment variables using the `environment_variables` data source +data "environment_variables" "required" { + filter = "ATMOS_.*" # Fetches all variables starting with "ATMOS_" +} diff --git a/examples/demo-env/components/terraform/example/outputs.tf b/examples/demo-env/components/terraform/example/outputs.tf new file mode 100644 index 000000000..79a4b5a1a --- /dev/null +++ b/examples/demo-env/components/terraform/example/outputs.tf @@ -0,0 +1,15 @@ +# Output environment variables +output "atmos_cli_config_path" { + value = data.environment_variables.required.items["ATMOS_CLI_CONFIG_PATH"] + description = "The path to the Atmos CLI configuration file" +} + +output "atmos_base_path" { + value = data.environment_variables.required.items["ATMOS_BASE_PATH"] + description = "The base path used by Atmos" +} + +output "example" { + value = data.environment_variables.required.items["EXAMPLE"] + description = "Example environment variable" +} diff --git a/examples/demo-env/stacks/catalog/example.yaml b/examples/demo-env/stacks/catalog/example.yaml new file mode 100644 index 000000000..409e8718e --- /dev/null +++ b/examples/demo-env/stacks/catalog/example.yaml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json +env: + EXAMPLE: "test" + +components: + terraform: + example: + metadata: + component: example + vars: {} diff --git a/examples/demo-env/stacks/deploy/dev.yaml b/examples/demo-env/stacks/deploy/dev.yaml new file mode 100644 index 000000000..d876c40a4 --- /dev/null +++ b/examples/demo-env/stacks/deploy/dev.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +vars: + stage: dev + +import: + - catalog/example + +components: + terraform: + example: + vars: {} diff --git a/examples/demo-env/stacks/deploy/prod.yaml b/examples/demo-env/stacks/deploy/prod.yaml new file mode 100644 index 000000000..75b7497b6 --- /dev/null +++ b/examples/demo-env/stacks/deploy/prod.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +vars: + stage: prod + +import: + - catalog/example + +components: + terraform: + example: + vars: {} diff --git a/examples/demo-env/stacks/deploy/staging.yaml b/examples/demo-env/stacks/deploy/staging.yaml new file mode 100644 index 000000000..beca3ad9f --- /dev/null +++ b/examples/demo-env/stacks/deploy/staging.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +vars: + stage: staging + +import: + - catalog/example + +components: + terraform: + example: + vars: {} diff --git a/internal/exec/helmfile.go b/internal/exec/helmfile.go index bb127902d..abfa5fb9f 100644 --- a/internal/exec/helmfile.go +++ b/internal/exec/helmfile.go @@ -244,6 +244,8 @@ func ExecuteHelmfile(info schema.ConfigAndStacksInfo) error { if cliConfig.Components.Helmfile.UseEKS { envVars = append(envVars, envVarsEKS...) } + envVars = append(envVars, fmt.Sprintf("ATMOS_CLI_CONFIG_PATH=%s", cliConfig.CliConfigPath)) + envVars = append(envVars, fmt.Sprintf("ATMOS_BASE_PATH=%s", cliConfig.BasePath)) u.LogTrace(cliConfig, "Using ENV vars:") for _, v := range envVars { diff --git a/internal/exec/terraform.go b/internal/exec/terraform.go index 88fd45b04..a35682e3d 100644 --- a/internal/exec/terraform.go +++ b/internal/exec/terraform.go @@ -231,6 +231,8 @@ func ExecuteTerraform(info schema.ConfigAndStacksInfo) error { } } + info.ComponentEnvList = append(info.ComponentEnvList, fmt.Sprintf("ATMOS_CLI_CONFIG_PATH=%s", cliConfig.CliConfigPath)) + info.ComponentEnvList = append(info.ComponentEnvList, fmt.Sprintf("ATMOS_BASE_PATH=%s", cliConfig.BasePath)) // Set `TF_IN_AUTOMATION` ENV var to `true` to suppress verbose instructions after terraform commands // https://developer.hashicorp.com/terraform/cli/config/environment-variables#tf_in_automation info.ComponentEnvList = append(info.ComponentEnvList, "TF_IN_AUTOMATION=true") diff --git a/pkg/config/config.go b/pkg/config/config.go index 73322b0ed..d21467019 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -2,13 +2,17 @@ package config import ( "bytes" + "context" "encoding/json" "fmt" + "net/url" "os" "path/filepath" "runtime" + "strings" + "time" - "github.com/fatih/color" + "github.com/hashicorp/go-getter" "github.com/mitchellh/go-homedir" "github.com/pkg/errors" "github.com/spf13/viper" @@ -96,16 +100,18 @@ var ( // https://medium.com/@bnprashanth256/reading-configuration-files-and-environment-variables-in-go-golang-c2607f912b63 func InitCliConfig(configAndStacksInfo schema.ConfigAndStacksInfo, processStacks bool) (schema.CliConfiguration, error) { // cliConfig is loaded from the following locations (from lower to higher priority): - // system dir (`/usr/local/etc/atmos` on Linux, `%LOCALAPPDATA%/atmos` on Windows) - // home dir (~/.atmos) - // current directory - // ENV vars - // Command-line arguments - + // 1. If ATMOS_CLI_CONFIG_PATH is defined, check only there + // 2. If ATMOS_CLI_CONFIG_PATH is not defined, proceed with other paths + // - Check system directory (optional) + // - Check user-specific configuration: + // - If XDG_CONFIG_HOME is defined, use it; otherwise fallback to ~/.config/atmos + // - Check current directory + // - If Terraform provider specified a path + // 3. If no config is found in any of the above locations, use the default config + // Check if no imports are defined var cliConfig schema.CliConfiguration var err error configFound := false - var found bool v := viper.New() v.SetConfigType("yaml") @@ -115,99 +121,109 @@ func InitCliConfig(configAndStacksInfo schema.ConfigAndStacksInfo, processStacks v.SetDefault("components.helmfile.use_eks", true) v.SetDefault("components.terraform.append_user_agent", fmt.Sprintf("Atmos/%s (Cloud Posse; +https://atmos.tools)", version.Version)) - // Process config in system folder - configFilePath1 := "" - - // https://pureinfotech.com/list-environment-variables-windows-10/ - // https://docs.microsoft.com/en-us/windows/deployment/usmt/usmt-recognized-environment-variables - // https://softwareengineering.stackexchange.com/questions/299869/where-is-the-appropriate-place-to-put-application-configuration-files-for-each-p - // https://stackoverflow.com/questions/37946282/why-does-appdata-in-windows-7-seemingly-points-to-wrong-folder - if runtime.GOOS == "windows" { - appDataDir := os.Getenv(WindowsAppDataEnvVar) - if len(appDataDir) > 0 { - configFilePath1 = appDataDir + // 1. If ATMOS_CLI_CONFIG_PATH is defined, check only there + if atmosCliConfigPathEnv := os.Getenv("ATMOS_CLI_CONFIG_PATH"); atmosCliConfigPathEnv != "" { + u.LogTrace(cliConfig, fmt.Sprintf("Found ENV var ATMOS_CLI_CONFIG_PATH=%s", atmosCliConfigPathEnv)) + configFile := filepath.Join(atmosCliConfigPathEnv, CliConfigFileName) + found, configPath, err := processConfigFile(cliConfig, configFile, v) + if err != nil { + return cliConfig, err + } + if !found { + // If we want to error out if config not found in ATMOS_CLI_CONFIG_PATH + return cliConfig, fmt.Errorf("config not found in ATMOS_CLI_CONFIG_PATH: %s", configFile) + } else { + configFound = true + cliConfig.CliConfigPath = configPath } + + // Since ATMOS_CLI_CONFIG_PATH is to be the first and only check, we skip other paths if found + // If not found, we still skip other paths as per the requirement. } else { - configFilePath1 = SystemDirConfigFilePath - } + // 2. If ATMOS_CLI_CONFIG_PATH is not defined, proceed with other paths + // - Check system directory (optional) + configFilePathSystem := "" + if runtime.GOOS == "windows" { + appDataDir := os.Getenv(WindowsAppDataEnvVar) + if len(appDataDir) > 0 { + configFilePathSystem = appDataDir + } + } else { + configFilePathSystem = SystemDirConfigFilePath + } + + if len(configFilePathSystem) > 0 { + configFile := filepath.Join(configFilePathSystem, CliConfigFileName) + found, configPath, err := processConfigFile(cliConfig, configFile, v) + if err != nil { + return cliConfig, err + } + if found { + configFound = true + cliConfig.CliConfigPath = configPath + } + } + + // 3. Check user-specific configuration: + // If XDG_CONFIG_HOME is defined, use it; otherwise fallback to ~/.config/atmos + xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") + var userConfigDir string + if xdgConfigHome != "" { + userConfigDir = filepath.Join(xdgConfigHome, "atmos") + } else { + homeDir, err := homedir.Dir() + if err != nil { + return cliConfig, err + } + userConfigDir = filepath.Join(homeDir, ".config", "atmos") + } - if len(configFilePath1) > 0 { - configFile1 := filepath.Join(configFilePath1, CliConfigFileName) - found, err = processConfigFile(cliConfig, configFile1, v) + userConfigFile := filepath.Join(userConfigDir, CliConfigFileName) + found, configPath, err := processConfigFile(cliConfig, userConfigFile, v) if err != nil { return cliConfig, err } if found { configFound = true + cliConfig.CliConfigPath = configPath } - } - - // Process config in user's HOME dir - configFilePath2, err := homedir.Dir() - if err != nil { - return cliConfig, err - } - configFile2 := filepath.Join(configFilePath2, ".atmos", CliConfigFileName) - found, err = processConfigFile(cliConfig, configFile2, v) - if err != nil { - return cliConfig, err - } - if found { - configFound = true - } - // Process config in the current dir - configFilePath3, err := os.Getwd() - if err != nil { - return cliConfig, err - } - configFile3 := filepath.Join(configFilePath3, CliConfigFileName) - found, err = processConfigFile(cliConfig, configFile3, v) - if err != nil { - return cliConfig, err - } - if found { - configFound = true - } - - // Process config from the path in ENV var `ATMOS_CLI_CONFIG_PATH` - configFilePath4 := os.Getenv("ATMOS_CLI_CONFIG_PATH") - if len(configFilePath4) > 0 { - u.LogTrace(cliConfig, fmt.Sprintf("Found ENV var ATMOS_CLI_CONFIG_PATH=%s", configFilePath4)) - configFile4 := filepath.Join(configFilePath4, CliConfigFileName) - found, err = processConfigFile(cliConfig, configFile4, v) + // 4. Check current directory + configFilePathCwd, err := os.Getwd() + if err != nil { + return cliConfig, err + } + configFileCwd := filepath.Join(configFilePathCwd, CliConfigFileName) + found, configPath, err = processConfigFile(cliConfig, configFileCwd, v) if err != nil { return cliConfig, err } if found { configFound = true + cliConfig.CliConfigPath = configPath } - } - // Process config from the path specified in the Terraform provider (which calls into the atmos code) - if configAndStacksInfo.AtmosCliConfigPath != "" { - configFilePath5 := configAndStacksInfo.AtmosCliConfigPath - if len(configFilePath5) > 0 { - configFile5 := filepath.Join(configFilePath5, CliConfigFileName) - found, err = processConfigFile(cliConfig, configFile5, v) + // 5. If Terraform provider specified a path + if configAndStacksInfo.AtmosCliConfigPath != "" { + configFileTfProvider := filepath.Join(configAndStacksInfo.AtmosCliConfigPath, CliConfigFileName) + found, configPath, err := processConfigFile(cliConfig, configFileTfProvider, v) if err != nil { return cliConfig, err } if found { configFound = true + cliConfig.CliConfigPath = configPath } } } if !configFound { - // If `atmos.yaml` not found, use the default config - // Set `ATMOS_LOGS_LEVEL` ENV var to "Debug" to see the message about Atmos using the default CLI config + // Use default config if no config was found in any location logsLevelEnvVar := os.Getenv("ATMOS_LOGS_LEVEL") if logsLevelEnvVar == u.LogLevelDebug || logsLevelEnvVar == u.LogLevelTrace { - u.PrintMessageInColor("'atmos.yaml' CLI config was not found in any of the searched paths: system dir, home dir, current dir, ENV vars.\n"+ - "Refer to https://atmos.tools/cli/configuration for details on how to configure 'atmos.yaml'.\n"+ - "Using the default CLI config:\n\n", color.New(color.FgCyan)) - + u.LogTrace(cliConfig, "atmos.yaml' CLI config was not found.\n"+ + "Refer to https://atmos.tools/cli/configuration\n"+ + "Using the default CLI config:\n\n") err = u.PrintAsYAMLToFileDescriptor(cliConfig, defaultCliConfig) if err != nil { return cliConfig, err @@ -227,19 +243,60 @@ func InitCliConfig(configAndStacksInfo schema.ConfigAndStacksInfo, processStacks } } - // https://gist.github.com/chazcheadle/45bf85b793dea2b71bd05ebaa3c28644 - // https://sagikazarmark.hu/blog/decoding-custom-formats-with-viper/ + // Unmarshal, process environment variables, imports, and command-line arguments as needed. err = v.Unmarshal(&cliConfig) if err != nil { return cliConfig, err } - // Process ENV vars err = processEnvVars(&cliConfig) if err != nil { return cliConfig, err } + // Check if no imports are defined + if len(cliConfig.Import) == 0 { + basePath, err := filepath.Abs(cliConfig.BasePath) + if err != nil { + return cliConfig, err + } + // Check for an `atmos.d` directory and load the configs if found + atmosDPath := filepath.Join(basePath, "atmos.d") + // Ensure the joined path doesn't escape the intended directory + if !strings.HasPrefix(atmosDPath, basePath) { + u.LogWarning(cliConfig, "invalid atmos.d path: attempted directory traversal") + } + _, err = os.Stat(atmosDPath) + if err == nil { + cliConfig.Import = []string{"atmos.d/**/*.yaml", "atmos.d/**/*.yml"} + } else if !os.IsNotExist(err) { + return cliConfig, err // Handle unexpected errors + } + // Check for `.atmos.d` directory if `.atmos.d` directory is not found + atmosDPath = filepath.Join(atmosDPath, ".atmos.d") + _, err = os.Stat(atmosDPath) + if err == nil { + cliImport := []string{".atmos.d/**/*.yaml", ".atmos.d/**/*.yml"} + cliConfig.Import = append(cliConfig.Import, cliImport...) + } else if !os.IsNotExist(err) { + return cliConfig, err // Handle unexpected errors + } + + } + // Process imports if any + if len(cliConfig.Import) > 0 { + err = processImports(cliConfig, v) + if err != nil { + return cliConfig, err + } + + // Re-unmarshal the merged configuration into cliConfig + err = v.Unmarshal(&cliConfig) + if err != nil { + return cliConfig, err + } + } + // Process command-line args err = processCommandLineArgs(&cliConfig, configAndStacksInfo) if err != nil { @@ -351,15 +408,15 @@ func processConfigFile( cliConfig schema.CliConfiguration, path string, v *viper.Viper, -) (bool, error) { +) (bool, string, error) { // Check if the config file exists configPath, fileExists := u.SearchConfigFile(path) if !fileExists { - return false, nil + return false, "", nil } reader, err := os.Open(configPath) if err != nil { - return false, err + return false, "", err } defer func(reader *os.File) { @@ -371,8 +428,103 @@ func processConfigFile( err = v.MergeConfig(reader) if err != nil { - return false, err + return false, "", err + } + + return true, configPath, nil +} +func processImports(cliConfig schema.CliConfiguration, v *viper.Viper) error { + for _, importPath := range cliConfig.Import { + if importPath == "" { + continue + } + + var resolvedPaths []string + var err error + + if strings.HasPrefix(importPath, "http://") || strings.HasPrefix(importPath, "https://") { + // Handle remote URLs + // Validate the URL before downloading + parsedURL, err := url.Parse(importPath) + if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") { + u.LogWarning(cliConfig, fmt.Sprintf("unsupported URL '%s': %v", importPath, err)) + continue + } + + tempDir, tempFile, err := downloadRemoteConfig(importPath) + if err != nil { + u.LogWarning(cliConfig, fmt.Sprintf("failed to download remote config '%s': %v", importPath, err)) + continue + } + resolvedPaths = []string{tempFile} + defer os.RemoveAll(tempDir) + + } else { + + ext := filepath.Ext(importPath) + + basePath, err := filepath.Abs(cliConfig.BasePath) + if err != nil { + return err + } + if ext != "" { + imp := filepath.Join(basePath, importPath) + resolvedPaths, err = u.GetGlobMatches(imp) + if err != nil { + u.LogWarning(cliConfig, fmt.Sprintf("failed to resolve import path '%s': %v", imp, err)) + continue + } + } else { + impYaml := filepath.Join(basePath, importPath+".yaml") + impYml := filepath.Join(basePath, importPath+".yml") + // ensure the joined path doesn't escape the intended directory + if !strings.HasPrefix(impYaml, basePath) || !strings.HasPrefix(impYml, basePath) { + return fmt.Errorf("invalid import path: attempted directory traversal") + } + resolvedPathYaml, errYaml := u.GetGlobMatches(impYaml) + resolvedPathsYml, errYml := u.GetGlobMatches(impYml) + if errYaml != nil && errYml != nil { + u.LogWarning(cliConfig, fmt.Sprintf("failed to resolve import path '%s': %v", importPath, err)) + continue + } + resolvedPaths = append(resolvedPaths, resolvedPathYaml...) + resolvedPaths = append(resolvedPaths, resolvedPathsYml...) + + } + } + // print the resolved paths + u.LogTrace(cliConfig, fmt.Sprintf("Resolved import paths: %v", resolvedPaths)) + for _, path := range resolvedPaths { + // Process each configuration file + _, _, err = processConfigFile(cliConfig, path, v) + if err != nil { + // Log the error but continue processing other files + u.LogWarning(cliConfig, fmt.Sprintf("failed to merge configuration from '%s': %v", path, err)) + continue + } + } } + return nil +} - return true, nil +func downloadRemoteConfig(url string) (string, string, error) { + tempDir, err := os.MkdirTemp("", "atmos-import-*") + if err != nil { + return "", "", err + } + tempFile := filepath.Join(tempDir, "atmos.yaml") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client := &getter.Client{ + Ctx: ctx, + Src: url, + Dst: tempFile, + Mode: getter.ClientModeFile, + } + err = client.Get() + if err != nil { + os.RemoveAll(tempDir) + return "", "", fmt.Errorf("failed to download remote config: %w", err) + } + return tempDir, tempFile, nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 000000000..02e9e38b8 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,138 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/cloudposse/atmos/pkg/schema" +) + +// Config loads successfully from ATMOS_CLI_CONFIG_PATH when env var is set +func TestInitCliConfigLoadsFromAtmosCliConfigPath(t *testing.T) { + // Setup test directory and config file + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "atmos.yaml") + + // Create test config content + configContent := []byte(` +components: + terraform: + base_path: terraform +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/_defaults.yaml" + name_pattern: "{stage}" +`) + err := os.WriteFile(configPath, configContent, 0644) + require.NoError(t, err) + + // Set env var to temp config path + t.Setenv("ATMOS_CLI_CONFIG_PATH", tmpDir) + + // Create test input + configInfo := schema.ConfigAndStacksInfo{ + Stack: "test-stack", + } + + // Call function under test + cfg, err := InitCliConfig(configInfo, false) + require.NoError(t, err) + + // Assert results + require.True(t, cfg.Initialized) + require.Equal(t, "terraform", cfg.Components.Terraform.BasePath) +} + +// Empty or invalid ATMOS_CLI_CONFIG_PATH environment variable +func TestInitCliConfigWithInvalidEnvPath(t *testing.T) { + // Set env var to non-existent path + t.Setenv("ATMOS_CLI_CONFIG_PATH", "/non/existent/path") + + // Create test input + configInfo := schema.ConfigAndStacksInfo{ + Stack: "test-stack", + } + + // Call function under test + _, err := InitCliConfig(configInfo, false) + + // Assert error is returned + require.Error(t, err) + require.Contains(t, err.Error(), "config not found in ATMOS_CLI_CONFIG_PATH") +} + +// Config loads from default locations when ATMOS_CLI_CONFIG_PATH is not set +func TestConfigLoadsFromDefaultLocations(t *testing.T) { + originalValue := os.Getenv("ATMOS_CLI_CONFIG_PATH") + defer os.Setenv("ATMOS_CLI_CONFIG_PATH", originalValue) + os.Unsetenv("ATMOS_CLI_CONFIG_PATH") + + // Setup temporary directory and default config file + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "atmos.yaml") + configContent := []byte(` +components: + helmfile: + use_eks: true + terraform: + append_user_agent: Atmos/1.0.0 (Cloud Posse; +https://atmos.tools) +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/_defaults.yaml" + name_pattern: "{stage}" +`) + err := os.WriteFile(configPath, configContent, 0644) + require.NoError(t, err) + + // Change working directory to temporary directory + err = os.Chdir(tmpDir) + require.NoError(t, err) + + configAndStacksInfo := schema.ConfigAndStacksInfo{} + cliConfig, err := InitCliConfig(configAndStacksInfo, false) + require.NoError(t, err) + require.True(t, cliConfig.Initialized) +} + +// Imports from atmos.d directory are processed automatically when no explicit imports defined +func TestImportsFromAtmosDProcessedAutomatically(t *testing.T) { + originalValue := os.Getenv("ATMOS_CLI_CONFIG_PATH") + defer os.Setenv("ATMOS_CLI_CONFIG_PATH", originalValue) + os.Unsetenv("ATMOS_CLI_CONFIG_PATH") + + // Setup temporary directory and atmos.d + tmpDir := t.TempDir() + atmosDPath := filepath.Join(tmpDir, "atmos.d") + err := os.Mkdir(atmosDPath, 0755) + require.NoError(t, err) + + // Create a sample import file + importFilePath := filepath.Join(atmosDPath, "sample.yaml") + importContent := []byte(` +imports: + - some/import/path.yaml +`) + err = os.WriteFile(importFilePath, importContent, 0644) + require.NoError(t, err) + + // Change working directory to temporary directory + err = os.Chdir(tmpDir) + require.NoError(t, err) + + configAndStacksInfo := schema.ConfigAndStacksInfo{} + cliConfig, err := InitCliConfig(configAndStacksInfo, false) + require.NoError(t, err) + + if len(cliConfig.Import) == 0 { + t.Fatalf("Expected imports to be processed from atmos.d directory") + } +} diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index e44396a38..c74de5abc 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -5,6 +5,7 @@ type AtmosSectionMapType = map[string]any // CliConfiguration structure represents schema for `atmos.yaml` CLI config type CliConfiguration struct { BasePath string `yaml:"base_path" json:"base_path" mapstructure:"base_path"` + CliConfigPath string `yaml:"cli_config_path" json:"cli_config_path,omitempty" mapstructure:"cli_config_path"` Components Components `yaml:"components" json:"components" mapstructure:"components"` Stacks Stacks `yaml:"stacks" json:"stacks" mapstructure:"stacks"` Workflows Workflows `yaml:"workflows,omitempty" json:"workflows,omitempty" mapstructure:"workflows"` @@ -26,6 +27,7 @@ type CliConfiguration struct { StackConfigFilesAbsolutePaths []string `yaml:"stackConfigFilesAbsolutePaths,omitempty" json:"stackConfigFilesAbsolutePaths,omitempty" mapstructure:"stackConfigFilesAbsolutePaths"` StackType string `yaml:"stackType,omitempty" json:"StackType,omitempty" mapstructure:"stackType"` Default bool `yaml:"default" json:"default" mapstructure:"default"` + Import []string `yaml:"import" json:"import" mapstructure:"import"` Version Version `yaml:"version,omitempty" json:"version,omitempty" mapstructure:"version"` } diff --git a/pkg/utils/glob_utils.go b/pkg/utils/glob_utils.go index 268473c53..c2e2f2d0a 100644 --- a/pkg/utils/glob_utils.go +++ b/pkg/utils/glob_utils.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" @@ -38,6 +39,8 @@ func GetGlobMatches(pattern string) ([]string, error) { for _, match := range matches { fullMatches = append(fullMatches, filepath.Join(base, match)) } + // Sort matches lexicographically + sort.Strings(fullMatches) getGlobMatchesSyncMap.Store(pattern, strings.Join(fullMatches, ",")) diff --git a/website/docs/cli/configuration/configuration.mdx b/website/docs/cli/configuration/configuration.mdx index 9268c0cb3..97c1b9b2d 100644 --- a/website/docs/cli/configuration/configuration.mdx +++ b/website/docs/cli/configuration/configuration.mdx @@ -10,6 +10,7 @@ import Screengrab from '@site/src/components/Screengrab' import Terminal from '@site/src/components/Terminal' import File from '@site/src/components/File' import Intro from '@site/src/components/Intro' +import Admonition from '@theme/Admonition'; # CLI Configuration @@ -17,30 +18,203 @@ import Intro from '@site/src/components/Intro' Use the `atmos.yaml` configuration file to control the behavior of the [`atmos` CLI](/cli) -Everything in the [`atmos` CLI](/cli) is configurable. The defaults are established in the `atmos.yaml` configuration file. The CLI configuration should not -be confused with [Stack configurations](/core-concepts/stacks/), which have a different schema. +Everything in the [`atmos` CLI](/cli) is configurable. The defaults are established in the `atmos.yaml` configuration file. The CLI configuration should not be confused with [Stack configurations](/core-concepts/stacks/), which have a different schema. -Think of this file as where you [bootstrap the settings or configuration of your project](/core-concepts/projects). If you'll be using -[terraform](/core-concepts/components/terraform), then [this is where](/cli/configuration/components#terraform-component-behavior) -you'd specify the command to run (e.g. [`opentofu`](/core-concepts/projects/configuration/opentofu)), -the base path location of the components, and so forth. +Think of this file as where you [bootstrap the settings or configuration of your project](/core-concepts/projects). If you'll be using [terraform](/core-concepts/components/terraform), then [this is where](/cli/configuration/components#terraform-component-behavior) you'd specify the command to run (e.g. [`opentofu`](/core-concepts/projects/configuration/opentofu)), the base path location of the components, and so forth. ## Configuration File (`atmos.yaml`) -The CLI config is loaded from the following locations (from lowest to highest priority): +The Atmos configuration is processed in three stages, allowing you to define settings at different levels of precedence. -- System directory (`/usr/local/etc/atmos/atmos.yaml` on Linux, `%LOCALAPPDATA%/atmos/atmos.yaml` on Windows) -- Home directory (`~/.atmos/atmos.yaml`) -- Current directory (`./atmos.yaml`) -- Environment variable `ATMOS_CLI_CONFIG_PATH` (the ENV var should point to a folder without specifying the file name) +As part of this process, Atmos updates the `ATMOS_CLI_CONFIG_PATH` environment variable to include a list of the absolute configuration paths in the order they were processed. These paths are separated by the system's standard path separator (`:` on Unix-like systems and `;` on Windows). This ensures that subshells and Terraform providers like `terraform-provider-utils` can consistently access and process the merged configurations across different operating systems. -Each configuration file discovered is deep-merged with the preceding configurations. +### Stage 1: Load Explicit or System-Wide Configurations + +Atmos will search for the configuration file in the following locations, in order of precedence. The first location found is used: +