Skip to content

Commit 80d5c44

Browse files
ostermanclaude
andcommitted
feat: Add global env section to atmos.yaml
Add support for a root-level `env` section in atmos.yaml that applies environment variables to all subprocesses spawned by Atmos. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent f3f594e commit 80d5c44

File tree

18 files changed

+695
-63
lines changed

18 files changed

+695
-63
lines changed

cmd/auth_exec.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
errUtils "github.com/cloudposse/atmos/errors"
1616
cfg "github.com/cloudposse/atmos/pkg/config"
17+
envpkg "github.com/cloudposse/atmos/pkg/env"
1718
log "github.com/cloudposse/atmos/pkg/logger"
1819
"github.com/cloudposse/atmos/pkg/schema"
1920
)
@@ -106,8 +107,9 @@ func executeAuthExecCommandCore(cmd *cobra.Command, args []string) error {
106107
}
107108

108109
// Prepare shell environment with file-based credentials.
109-
// Start with current OS environment and let PrepareShellEnvironment configure auth.
110-
envList, err := authManager.PrepareShellEnvironment(ctx, identityName, os.Environ())
110+
// Start with current OS environment + global env from atmos.yaml and let PrepareShellEnvironment configure auth.
111+
baseEnv := envpkg.MergeGlobalEnv(os.Environ(), atmosConfig.Env)
112+
envList, err := authManager.PrepareShellEnvironment(ctx, identityName, baseEnv)
111113
if err != nil {
112114
return fmt.Errorf("failed to prepare command environment: %w", err)
113115
}

cmd/cmd_utils.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/cloudposse/atmos/pkg/auth/credentials"
2424
"github.com/cloudposse/atmos/pkg/auth/validation"
2525
cfg "github.com/cloudposse/atmos/pkg/config"
26+
envpkg "github.com/cloudposse/atmos/pkg/env"
2627
l "github.com/cloudposse/atmos/pkg/list"
2728
log "github.com/cloudposse/atmos/pkg/logger"
2829
"github.com/cloudposse/atmos/pkg/perf"
@@ -462,8 +463,8 @@ func executeCustomCommand(
462463

463464
// Prepare ENV vars
464465
// ENV var values support Go templates and have access to {{ .ComponentConfig.xxx.yyy.zzz }} Go template variables
465-
// Start with current environment to inherit PATH and other variables.
466-
env := os.Environ()
466+
// Start with current environment + global env from atmos.yaml to inherit PATH and other variables.
467+
env := envpkg.MergeGlobalEnv(os.Environ(), atmosConfig.Env)
467468
for _, v := range commandConfig.Env {
468469
key := strings.TrimSpace(v.Key)
469470
value := v.Value
@@ -489,7 +490,7 @@ func executeCustomCommand(
489490
}
490491

491492
// Add or update the environment variable in the env slice
492-
env = u.UpdateEnvVar(env, key, value)
493+
env = envpkg.UpdateEnvVar(env, key, value)
493494
}
494495

495496
if len(commandConfig.Env) > 0 && commandConfig.Verbose {

internal/exec/shell_utils.go

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/spf13/viper"
1717

1818
errUtils "github.com/cloudposse/atmos/errors"
19+
envpkg "github.com/cloudposse/atmos/pkg/env"
1920
log "github.com/cloudposse/atmos/pkg/logger"
2021
"github.com/cloudposse/atmos/pkg/perf"
2122
"github.com/cloudposse/atmos/pkg/schema"
@@ -49,7 +50,10 @@ func ExecuteShellCommand(
4950
updatedEnv := append(env, fmt.Sprintf("ATMOS_SHLVL=%d", newShellLevel))
5051

5152
cmd := exec.Command(command, args...)
52-
cmd.Env = append(os.Environ(), updatedEnv...)
53+
// Build environment: os.Environ() + global env (atmos.yaml) + command-specific env.
54+
// Global env has lowest priority after system env, command-specific env overrides both.
55+
baseEnv := envpkg.MergeGlobalEnv(os.Environ(), atmosConfig.Env)
56+
cmd.Env = append(baseEnv, updatedEnv...)
5357
cmd.Dir = dir
5458
cmd.Stdin = os.Stdin
5559
cmd.Stdout = os.Stdout
@@ -110,7 +114,7 @@ func ExecuteShell(
110114
command string,
111115
name string,
112116
dir string,
113-
env []string,
117+
envVars []string,
114118
dryRun bool,
115119
) error {
116120
defer perf.Track(nil, "exec.ExecuteShell")()
@@ -126,8 +130,8 @@ func ExecuteShell(
126130
// This matches the behavior before commit 9fd7d156a where the environment
127131
// was merged rather than replaced.
128132
mergedEnv := os.Environ()
129-
for _, envVar := range env {
130-
mergedEnv = u.UpdateEnvVar(mergedEnv, parseEnvVarKey(envVar), parseEnvVarValue(envVar))
133+
for _, envVar := range envVars {
134+
mergedEnv = envpkg.UpdateEnvVar(mergedEnv, parseEnvVarKey(envVar), parseEnvVarValue(envVar))
131135
}
132136

133137
mergedEnv = append(mergedEnv, fmt.Sprintf("ATMOS_SHLVL=%d", newShellLevel))
@@ -243,8 +247,9 @@ func execTerraformShellCommand(
243247

244248
log.Debug("Setting the ENV vars in the shell")
245249

246-
// Merge env vars, ensuring componentEnvList takes precedence
247-
mergedEnv := mergeEnvVars(componentEnvList)
250+
// Merge env vars, ensuring componentEnvList takes precedence.
251+
// Include global env from atmos.yaml (lowest priority after system env).
252+
mergedEnv := mergeEnvVarsWithGlobal(componentEnvList, atmosConfig.Env)
248253

249254
// Transfer stdin, stdout, and stderr to the new process and also set the target directory for the shell to start in
250255
pa := os.ProcAttr{
@@ -341,7 +346,8 @@ func ExecAuthShellCommand(
341346
printShellEnterMessage(identityName, providerName)
342347

343348
// Merge env vars, ensuring authEnvList takes precedence.
344-
mergedEnv := mergeEnvVarsSimple(authEnvList)
349+
// Include global env from atmos.yaml (lowest priority after system env).
350+
mergedEnv := mergeEnvVarsSimpleWithGlobal(authEnvList, atmosConfig.Env)
345351

346352
// Determine shell command and args.
347353
shellCommand, shellCommandArgs := determineShell(shellOverride, shellArgs)
@@ -529,6 +535,52 @@ func mergeEnvVars(componentEnvList []string) []string {
529535
return merged
530536
}
531537

538+
// mergeEnvVarsWithGlobal is like mergeEnvVars but also includes global env from atmos.yaml.
539+
// Priority order: system env < global env (atmos.yaml) < component env.
540+
func mergeEnvVarsWithGlobal(componentEnvList []string, globalEnv map[string]string) []string {
541+
envMap := make(map[string]string)
542+
543+
// Parse system environment variables.
544+
for _, env := range os.Environ() {
545+
if parts := strings.SplitN(env, "=", 2); len(parts) == 2 {
546+
envMap[parts[0]] = parts[1]
547+
}
548+
}
549+
550+
// Apply global env from atmos.yaml (can override system env).
551+
for k, v := range globalEnv {
552+
envMap[k] = v
553+
}
554+
555+
// Merge with new, Atmos defined environment variables (highest priority).
556+
for _, env := range componentEnvList {
557+
if parts := strings.SplitN(env, "=", 2); len(parts) == 2 {
558+
// Special handling for Terraform CLI arguments environment variables.
559+
if strings.HasPrefix(parts[0], "TF_CLI_ARGS_") {
560+
// For TF_CLI_ARGS_* variables, we need to append new values to any existing values.
561+
if existing, exists := envMap[parts[0]]; exists {
562+
// Put the new, Atmos defined value first so it takes precedence.
563+
envMap[parts[0]] = parts[1] + " " + existing
564+
} else {
565+
// No existing value, just set the new value.
566+
envMap[parts[0]] = parts[1]
567+
}
568+
} else {
569+
// For all other environment variables, just override any existing value.
570+
envMap[parts[0]] = parts[1]
571+
}
572+
}
573+
}
574+
575+
// Convert back to slice.
576+
merged := make([]string, 0, len(envMap))
577+
for k, v := range envMap {
578+
log.Trace("Setting ENV var", "key", k, "value", v)
579+
merged = append(merged, k+"="+v)
580+
}
581+
return merged
582+
}
583+
532584
// mergeEnvVarsSimple adds a list of environment variables to the system environment variables without special handling.
533585
func mergeEnvVarsSimple(newEnvList []string) []string {
534586
envMap := make(map[string]string)
@@ -556,6 +608,39 @@ func mergeEnvVarsSimple(newEnvList []string) []string {
556608
return merged
557609
}
558610

611+
// mergeEnvVarsSimpleWithGlobal is like mergeEnvVarsSimple but includes global env from atmos.yaml.
612+
// Priority order: system env < global env (atmos.yaml) < new env list.
613+
func mergeEnvVarsSimpleWithGlobal(newEnvList []string, globalEnv map[string]string) []string {
614+
envMap := make(map[string]string)
615+
616+
// Parse system environment variables.
617+
for _, env := range os.Environ() {
618+
if parts := strings.SplitN(env, envVarSeparator, 2); len(parts) == 2 {
619+
envMap[parts[0]] = parts[1]
620+
}
621+
}
622+
623+
// Apply global env from atmos.yaml (can override system env).
624+
for k, v := range globalEnv {
625+
envMap[k] = v
626+
}
627+
628+
// Merge with new environment variables (override any existing).
629+
for _, env := range newEnvList {
630+
if parts := strings.SplitN(env, envVarSeparator, 2); len(parts) == 2 {
631+
envMap[parts[0]] = parts[1]
632+
}
633+
}
634+
635+
// Convert back to slice.
636+
merged := make([]string, 0, len(envMap))
637+
for k, v := range envMap {
638+
log.Trace("Setting ENV var", "key", k, "value", v)
639+
merged = append(merged, k+envVarSeparator+v)
640+
}
641+
return merged
642+
}
643+
559644
// printShellEnterMessage prints a user-facing message when entering an Atmos-managed shell.
560645
func printShellEnterMessage(identityName, providerName string) {
561646
headerStyle := lipgloss.NewStyle().

internal/exec/shell_utils_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,9 @@ func TestExecAuthShellCommand_ExitCodePropagation(t *testing.T) {
460460
envVars := map[string]string{
461461
"TEST_VAR": "test_value",
462462
}
463+
atmosConfig := &schema.AtmosConfiguration{}
463464

464-
err := ExecAuthShellCommand(nil, "test-identity", "test-provider", envVars, "/bin/sh", tt.shellArgs)
465+
err := ExecAuthShellCommand(atmosConfig, "test-identity", "test-provider", envVars, "/bin/sh", tt.shellArgs)
465466

466467
if tt.expectedCode == 0 {
467468
assert.NoError(t, err)

internal/exec/stack_processor_process_stacks.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ const (
2121
errFormatWithFile = "%w in file '%s'"
2222
)
2323

24+
// convertEnvMapStringToAny converts map[string]string to map[string]any.
25+
// This is needed because stack processing uses map[string]any for env sections.
26+
func convertEnvMapStringToAny(env map[string]string) map[string]any {
27+
if env == nil {
28+
return nil
29+
}
30+
result := make(map[string]any, len(env))
31+
for k, v := range env {
32+
result[k] = v
33+
}
34+
return result
35+
}
36+
2437
// ProcessStackConfig processes a stack configuration.
2538
//
2639
//nolint:gocognit,nestif,revive,cyclop,funlen // Core stack processing logic with complex configuration handling.
@@ -198,7 +211,9 @@ func ProcessStackConfig(
198211
}
199212
}
200213

201-
globalAndTerraformEnv, err := m.Merge(atmosConfig, []map[string]any{globalEnvSection, terraformEnv})
214+
// Include atmos.yaml global env as lowest priority in the merge chain.
215+
atmosConfigEnv := convertEnvMapStringToAny(atmosConfig.Env)
216+
globalAndTerraformEnv, err := m.Merge(atmosConfig, []map[string]any{atmosConfigEnv, globalEnvSection, terraformEnv})
202217
if err != nil {
203218
return nil, err
204219
}
@@ -297,7 +312,8 @@ func ProcessStackConfig(
297312
}
298313
}
299314

300-
globalAndHelmfileEnv, err := m.Merge(atmosConfig, []map[string]any{globalEnvSection, helmfileEnv})
315+
// Include atmos.yaml global env as lowest priority in the merge chain.
316+
globalAndHelmfileEnv, err := m.Merge(atmosConfig, []map[string]any{atmosConfigEnv, globalEnvSection, helmfileEnv})
301317
if err != nil {
302318
return nil, err
303319
}
@@ -353,7 +369,8 @@ func ProcessStackConfig(
353369
}
354370
}
355371

356-
globalAndPackerEnv, err := m.Merge(atmosConfig, []map[string]any{globalEnvSection, packerEnv})
372+
// Include atmos.yaml global env as lowest priority in the merge chain.
373+
globalAndPackerEnv, err := m.Merge(atmosConfig, []map[string]any{atmosConfigEnv, globalEnvSection, packerEnv})
357374
if err != nil {
358375
return nil, err
359376
}

internal/exec/utils.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
errUtils "github.com/cloudposse/atmos/errors"
1818
auth "github.com/cloudposse/atmos/pkg/auth"
1919
cfg "github.com/cloudposse/atmos/pkg/config"
20+
"github.com/cloudposse/atmos/pkg/env"
2021
log "github.com/cloudposse/atmos/pkg/logger"
2122
"github.com/cloudposse/atmos/pkg/perf"
2223
"github.com/cloudposse/atmos/pkg/schema"
@@ -603,7 +604,7 @@ func ProcessStacks(
603604
}
604605

605606
// Process the ENV variables from the `env` section.
606-
configAndStacksInfo.ComponentEnvList = u.ConvertEnvVars(configAndStacksInfo.ComponentEnvSection)
607+
configAndStacksInfo.ComponentEnvList = env.ConvertEnvVars(configAndStacksInfo.ComponentEnvSection)
607608

608609
// Process component metadata.
609610
_, baseComponentName, _, componentIsEnabled, componentIsLocked := ProcessComponentMetadata(configAndStacksInfo.ComponentFromArg, configAndStacksInfo.ComponentSection)

internal/exec/validate_component.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
errUtils "github.com/cloudposse/atmos/errors"
1212
cfg "github.com/cloudposse/atmos/pkg/config"
13+
"github.com/cloudposse/atmos/pkg/env"
1314
log "github.com/cloudposse/atmos/pkg/logger"
1415
"github.com/cloudposse/atmos/pkg/perf"
1516
"github.com/cloudposse/atmos/pkg/schema"
@@ -244,7 +245,7 @@ func validateComponentInternal(
244245
var ok bool
245246

246247
// Add the process environment variables to the component section.
247-
componentSection[cfg.ProcessEnvSectionName] = u.EnvironToMap()
248+
componentSection[cfg.ProcessEnvSectionName] = env.EnvironToMap()
248249

249250
switch schemaType {
250251
case "jsonschema":

internal/exec/workflow_utils.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/cloudposse/atmos/pkg/auth/credentials"
1919
"github.com/cloudposse/atmos/pkg/auth/validation"
2020
"github.com/cloudposse/atmos/pkg/config"
21+
envpkg "github.com/cloudposse/atmos/pkg/env"
2122
log "github.com/cloudposse/atmos/pkg/logger"
2223
"github.com/cloudposse/atmos/pkg/perf"
2324
"github.com/cloudposse/atmos/pkg/retry"
@@ -159,7 +160,9 @@ func ExecuteWorkflow(
159160
commandType = "atmos"
160161
}
161162

162-
// Prepare environment variables if identity is specified for this step.
163+
// Prepare environment variables: start with system env + global env from atmos.yaml.
164+
// Global env has lowest priority and can be overridden by identity auth env vars.
165+
baseEnv := envpkg.MergeGlobalEnv(os.Environ(), atmosConfig.Env)
163166
var stepEnv []string
164167
if stepIdentity != "" {
165168
if authManager == nil {
@@ -185,16 +188,16 @@ func ExecuteWorkflow(
185188
}
186189

187190
// Prepare shell environment with authentication credentials.
188-
// Start with current OS environment and let PrepareShellEnvironment configure auth.
189-
stepEnv, err = authManager.PrepareShellEnvironment(ctx, stepIdentity, os.Environ())
191+
// Start with base environment (system + global) and let PrepareShellEnvironment configure auth.
192+
stepEnv, err = authManager.PrepareShellEnvironment(ctx, stepIdentity, baseEnv)
190193
if err != nil {
191194
return fmt.Errorf("failed to prepare shell environment for identity %q in step %q: %w", stepIdentity, step.Name, err)
192195
}
193196

194197
log.Debug("Prepared environment with identity", "identity", stepIdentity, "step", step.Name)
195198
} else {
196-
// No identity specified, use empty environment (subprocess inherits from parent).
197-
stepEnv = []string{}
199+
// No identity specified, use base environment (system + global env).
200+
stepEnv = baseEnv
198201
}
199202

200203
var err error

0 commit comments

Comments
 (0)