Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
eae6789
feat: Add global env section to atmos.yaml
osterman Dec 3, 2025
778dac3
refactor: Address CodeRabbit review feedback
osterman Dec 3, 2025
70311ff
refactor: Move mergeEnvVars functions to pkg/env package
osterman Dec 3, 2025
08694c2
refactor: Move additional env functions to pkg/env package
osterman Dec 3, 2025
577cfe6
fix: Use exact path matching in EnsureBinaryInPath
osterman Dec 3, 2025
baeccf2
feat(env): Add fluent builder pattern for constructing env slices
osterman Dec 3, 2025
6134394
refactor(env): Remove wrapper functions, call env pkg directly
osterman Dec 3, 2025
b90c4fd
refactor(env): Remove convertEnvMapToSlice, use envpkg.ConvertMapToSlice
osterman Dec 3, 2025
0e04b30
docs: Add CLI configuration page for global env section
osterman Dec 5, 2025
4a96d09
docs: Add cross-references to stack-level env documentation
osterman Dec 5, 2025
ff892a2
docs: Use YAML functions instead of template functions in env docs
osterman Dec 5, 2025
53aee88
docs: Fix GITHUB_TOKEN example to use !exec gh auth token
osterman Dec 5, 2025
daabe94
docs: Fix YAML function syntax in env documentation
osterman Dec 5, 2025
a7f6574
docs: Fix shell expansion and hyperlink issues in env documentation
osterman Dec 5, 2025
f5f8ca8
feat: Add `atmos env` command for outputting global environment varia…
osterman Dec 5, 2025
0f12d7e
docs: Add demo-env example using GitHub provider with atmos env
osterman Dec 5, 2025
ef7026b
fix: Use name_template with vars.stage in demo-env example
osterman Dec 5, 2025
226d008
fix: Update demo-env example to use available GitHub provider attributes
osterman Dec 5, 2025
0ac2d43
refactor: Rename github-stars component to github-repo in demo-env
osterman Dec 5, 2025
a3b51ac
docs: Improve prerequisites in demo-env README
osterman Dec 5, 2025
784156f
fix: Preserve case for env var names and strip trailing newlines from…
osterman Dec 5, 2025
abe3d8a
fix: Use unique heredoc delimiter and add security docs
osterman Dec 5, 2025
52bc0f2
fix: env command respects CLI config overrides and improve logging
osterman Dec 9, 2025
8edc265
fix: resolve mapstructure tag collision causing commands to be dropped
osterman Dec 13, 2025
11471a3
fix: address CodeRabbit review feedback
osterman Dec 13, 2025
d8194e1
refactor: migrate env command to standard flags pattern
osterman Dec 13, 2025
98704f8
fix: update Valid_Log_Level_in_Config_File snapshot for trace log
osterman Dec 13, 2025
4181a18
Address CodeRabbit review comments
osterman Dec 14, 2025
0596c2d
fix: regenerate help snapshots to include env command
osterman Dec 15, 2025
4fcd860
fix: align remote import log level and fix test signature
osterman Dec 16, 2025
a5712a7
fix: regenerate non-existent command snapshot to include env
osterman Dec 16, 2025
8260c57
Merge branch 'main' into osterman/global-env-section
osterman Dec 16, 2025
66d70c4
Merge branch 'main' into osterman/global-env-section
aknysh Dec 16, 2025
36863d8
fix: Use ErrorBuilder for config loading error in env command
aknysh Dec 16, 2025
38c4613
test: Add tests to increase coverage for global env feature
aknysh Dec 16, 2025
8c5761b
address comments, add tests
aknysh Dec 16, 2025
4f6b2de
address comments, add tests
aknysh Dec 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cmd/auth_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

errUtils "github.com/cloudposse/atmos/errors"
cfg "github.com/cloudposse/atmos/pkg/config"
envpkg "github.com/cloudposse/atmos/pkg/env"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/schema"
)
Expand Down Expand Up @@ -106,8 +107,9 @@ func executeAuthExecCommandCore(cmd *cobra.Command, args []string) error {
}

// Prepare shell environment with file-based credentials.
// Start with current OS environment and let PrepareShellEnvironment configure auth.
envList, err := authManager.PrepareShellEnvironment(ctx, identityName, os.Environ())
// Start with current OS environment + global env from atmos.yaml and let PrepareShellEnvironment configure auth.
baseEnv := envpkg.MergeGlobalEnv(os.Environ(), atmosConfig.Env)
envList, err := authManager.PrepareShellEnvironment(ctx, identityName, baseEnv)
if err != nil {
return fmt.Errorf("failed to prepare command environment: %w", err)
}
Expand Down
7 changes: 4 additions & 3 deletions cmd/cmd_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/cloudposse/atmos/pkg/auth/credentials"
"github.com/cloudposse/atmos/pkg/auth/validation"
cfg "github.com/cloudposse/atmos/pkg/config"
envpkg "github.com/cloudposse/atmos/pkg/env"
l "github.com/cloudposse/atmos/pkg/list"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
Expand Down Expand Up @@ -549,8 +550,8 @@ func executeCustomCommand(

// Prepare ENV vars
// ENV var values support Go templates and have access to {{ .ComponentConfig.xxx.yyy.zzz }} Go template variables
// Start with current environment to inherit PATH and other variables.
env := os.Environ()
// Start with current environment + global env from atmos.yaml to inherit PATH and other variables.
env := envpkg.MergeGlobalEnv(os.Environ(), atmosConfig.Env)
for _, v := range commandConfig.Env {
key := strings.TrimSpace(v.Key)
value := v.Value
Expand All @@ -576,7 +577,7 @@ func executeCustomCommand(
}

// Add or update the environment variable in the env slice
env = u.UpdateEnvVar(env, key, value)
env = envpkg.UpdateEnvVar(env, key, value)
}

if len(commandConfig.Env) > 0 && commandConfig.Verbose {
Expand Down
285 changes: 285 additions & 0 deletions cmd/env/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
package env

import (
"fmt"
"os"
"slices"
"sort"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/cloudposse/atmos/cmd/internal"
errUtils "github.com/cloudposse/atmos/errors"
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/data"
"github.com/cloudposse/atmos/pkg/flags"
"github.com/cloudposse/atmos/pkg/flags/compat"
"github.com/cloudposse/atmos/pkg/schema"
u "github.com/cloudposse/atmos/pkg/utils"
)

const (
// DefaultFileMode is the file mode for output files.
defaultFileMode = 0o644
)

// SupportedFormats lists all supported output formats.
var SupportedFormats = []string{"bash", "json", "dotenv", "github"}

// envParser handles flag parsing with Viper precedence.
var envParser *flags.StandardParser

// envCmd outputs environment variables from atmos.yaml.
var envCmd = &cobra.Command{
Use: "env",
Short: "Output environment variables configured in atmos.yaml",
Long: `Outputs environment variables from the 'env' section of atmos.yaml in various formats suitable for shell evaluation, .env files, JSON consumption, or GitHub Actions workflows.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Parse flags using Viper (respects precedence: flags > env > config > defaults).
v := viper.GetViper()
if err := envParser.BindFlagsToViper(cmd, v); err != nil {
return err
}

// Get output format.
format := v.GetString("format")
if !slices.Contains(SupportedFormats, format) {
return errUtils.Build(errUtils.ErrInvalidArgumentError).
WithExplanationf("Invalid --format value %q.", format).
WithHintf("Supported formats: %s.", strings.Join(SupportedFormats, ", ")).
Err()
}

// Get output file path.
output := v.GetString("output")

// Build ConfigAndStacksInfo with CLI overrides (--config, --config-path, --base-path).
// These are persistent flags inherited from the root command.
configAndStacksInfo := schema.ConfigAndStacksInfo{}
if bp, _ := cmd.Flags().GetString("base-path"); bp != "" {
configAndStacksInfo.BasePath = bp
}
if cfgFiles, _ := cmd.Flags().GetStringSlice("config"); len(cfgFiles) > 0 {
configAndStacksInfo.AtmosConfigFilesFromArg = cfgFiles
}
if cfgDirs, _ := cmd.Flags().GetStringSlice("config-path"); len(cfgDirs) > 0 {
configAndStacksInfo.AtmosConfigDirsFromArg = cfgDirs
}

// Load atmos configuration (processStacks=false since env command doesn't require stack manifests).
atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, false)
if err != nil {
return errUtils.Build(errUtils.ErrMissingAtmosConfig).
WithCause(err).
WithExplanation("Failed to load atmos configuration.").
WithHint("Ensure atmos.yaml exists and is properly formatted.").
Err()
}

// Get env vars with original case preserved (Viper lowercases all YAML map keys).
envVars := atmosConfig.GetCaseSensitiveMap("env")
if envVars == nil {
envVars = make(map[string]string)
}

// Handle GitHub format special case.
if format == "github" {
if output == "" {
// GITHUB_ENV is an external CI environment variable set by GitHub Actions,
// not an Atmos configuration variable, so os.Getenv is appropriate here.
//nolint:forbidigo // GITHUB_ENV is an external CI env var, not Atmos config
output = os.Getenv("GITHUB_ENV")
if output == "" {
return errUtils.Build(errUtils.ErrRequiredFlagNotProvided).
WithExplanation("--format=github requires GITHUB_ENV environment variable to be set, or use --output to specify a file path.").
Err()
}
}
return writeEnvToFile(envVars, output, formatGitHub)
}

// Handle file output for other formats.
if output != "" {
var formatter func(map[string]string) string
switch format {
case "bash":
formatter = formatBash
case "dotenv":
formatter = formatDotenv
case "json":
// For JSON file output, use the utility function.
return u.WriteToFileAsJSON(output, envVars, defaultFileMode)
default:
formatter = formatBash
}
return writeEnvToFile(envVars, output, formatter)
}

// Output to stdout.
switch format {
case "json":
return outputEnvAsJSON(&atmosConfig, envVars)
case "bash":
return outputEnvAsBash(envVars)
case "dotenv":
return outputEnvAsDotenv(envVars)
default:
return outputEnvAsBash(envVars)
}
},
}

// outputEnvAsJSON outputs environment variables as JSON.
func outputEnvAsJSON(atmosConfig *schema.AtmosConfiguration, envVars map[string]string) error {
return u.PrintAsJSON(atmosConfig, envVars)
}

// outputEnvAsBash outputs environment variables as shell export statements.
func outputEnvAsBash(envVars map[string]string) error {
return data.Write(formatBash(envVars))
}

// outputEnvAsDotenv outputs environment variables in .env format.
func outputEnvAsDotenv(envVars map[string]string) error {
return data.Write(formatDotenv(envVars))
}

// formatBash formats environment variables as shell export statements.
func formatBash(envVars map[string]string) string {
keys := sortedKeys(envVars)
var sb strings.Builder
for _, key := range keys {
value := envVars[key]
// Escape single quotes for safe single-quoted shell literals: ' -> '\''.
safe := strings.ReplaceAll(value, "'", "'\\''")
sb.WriteString(fmt.Sprintf("export %s='%s'\n", key, safe))
}
return sb.String()
}

// formatDotenv formats environment variables in .env format.
func formatDotenv(envVars map[string]string) string {
keys := sortedKeys(envVars)
var sb strings.Builder
for _, key := range keys {
value := envVars[key]
// Use the same safe single-quoted escaping as bash output.
safe := strings.ReplaceAll(value, "'", "'\\''")
sb.WriteString(fmt.Sprintf("%s='%s'\n", key, safe))
}
return sb.String()
}

// formatGitHub formats environment variables for GitHub Actions $GITHUB_ENV file.
// Uses KEY=value format without quoting. For multiline values, GitHub uses heredoc syntax.
func formatGitHub(envVars map[string]string) string {
keys := sortedKeys(envVars)
var sb strings.Builder
for _, key := range keys {
value := envVars[key]
// Check if value contains newlines - use heredoc syntax.
// Use ATMOS_EOF_ prefix to avoid collision with values containing "EOF".
if strings.Contains(value, "\n") {
sb.WriteString(fmt.Sprintf("%s<<ATMOS_EOF_%s\n%s\nATMOS_EOF_%s\n", key, key, value, key))
} else {
sb.WriteString(fmt.Sprintf("%s=%s\n", key, value))
}
}
return sb.String()
}

// writeEnvToFile writes formatted environment variables to a file (append mode).
func writeEnvToFile(envVars map[string]string, filePath string, formatter func(map[string]string) string) error {
// Open file in append mode, create if doesn't exist.
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, defaultFileMode)
if err != nil {
return fmt.Errorf("failed to open file '%s': %w", filePath, err)
}
defer f.Close()

content := formatter(envVars)
if _, err := f.WriteString(content); err != nil {
return fmt.Errorf("failed to write to file '%s': %w", filePath, err)
}
return nil
}

// sortedKeys returns the keys of a map sorted alphabetically.
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

func init() {
// Create parser with env-specific flags using functional options.
envParser = flags.NewStandardParser(
flags.WithStringFlag("format", "f", "bash", "Output format: bash, json, dotenv, github"),
flags.WithStringFlag("output", "o", "", "Output file path (default: stdout, or $GITHUB_ENV for github format)"),
flags.WithEnvVars("format", "ATMOS_ENV_FORMAT"),
flags.WithEnvVars("output", "ATMOS_ENV_OUTPUT"),
)

// Register flags using the standard RegisterFlags method.
envParser.RegisterFlags(envCmd)

// Bind flags to Viper for environment variable support.
if err := envParser.BindToViper(viper.GetViper()); err != nil {
panic(err)
}

// Register format flag completion.
if err := envCmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return SupportedFormats, cobra.ShellCompDirectiveNoFileComp
}); err != nil {
// Silently ignore completion registration errors.
_ = err
}

// Register this command with the registry.
internal.Register(&EnvCommandProvider{})
}

// EnvCommandProvider implements the CommandProvider interface.
type EnvCommandProvider struct{}

// GetCommand returns the env command.
func (e *EnvCommandProvider) GetCommand() *cobra.Command {
return envCmd
}

// GetName returns the command name.
func (e *EnvCommandProvider) GetName() string {
return "env"
}

// GetGroup returns the command group for help organization.
func (e *EnvCommandProvider) GetGroup() string {
return "Configuration Management"
}

// GetFlagsBuilder returns the flags builder for this command.
func (e *EnvCommandProvider) GetFlagsBuilder() flags.Builder {
return envParser
}

// GetPositionalArgsBuilder returns the positional args builder for this command.
func (e *EnvCommandProvider) GetPositionalArgsBuilder() *flags.PositionalArgsBuilder {
return nil
}

// GetCompatibilityFlags returns compatibility flags for this command.
func (e *EnvCommandProvider) GetCompatibilityFlags() map[string]compat.CompatibilityFlag {
return nil
}

// GetAliases returns command aliases.
func (e *EnvCommandProvider) GetAliases() []internal.CommandAlias {
return nil
}
Loading
Loading