diff --git a/cmd/describe_locals.go b/cmd/describe_locals.go new file mode 100644 index 0000000000..0d90837d80 --- /dev/null +++ b/cmd/describe_locals.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/internal/exec" + cfg "github.com/cloudposse/atmos/pkg/config" + "github.com/cloudposse/atmos/pkg/perf" + "github.com/cloudposse/atmos/pkg/schema" +) + +const stackFlagName = "stack" + +// describeLocalsCmd describes locals for stacks. +var describeLocalsCmd = &cobra.Command{ + Use: "locals [component] -s ", + Short: "Display locals from Atmos stack manifests", + Long: `This command displays the locals defined in Atmos stack manifests. + +When called with --stack, it shows the locals defined in that stack manifest file. +When a component is also specified, it shows the merged locals that would be +available to that component (global + section-specific + component-level).`, + Example: ` # Show locals for a specific stack + atmos describe locals --stack deploy/dev + + # Show locals for a specific stack (using logical stack name) + atmos describe locals -s prod-us-east-1 + + # Show locals for a component in a stack + atmos describe locals vpc -s prod + + # Output as JSON + atmos describe locals --stack dev --format json + + # Query specific values + atmos describe locals -s deploy/dev --query '.["deploy/dev"].locals.namespace'`, + Args: cobra.MaximumNArgs(1), + RunE: getRunnableDescribeLocalsCmd(getRunnableDescribeLocalsCmdProps{ + checkAtmosConfig: checkAtmosConfig, + processCommandLineArgs: exec.ProcessCommandLineArgs, + initCliConfig: cfg.InitCliConfig, + validateStacks: exec.ValidateStacks, + newDescribeLocalsExecFactory: exec.NewDescribeLocalsExec, + }), +} + +type getRunnableDescribeLocalsCmdProps struct { + checkAtmosConfig func(opts ...AtmosValidateOption) + processCommandLineArgs func( + componentType string, + cmd *cobra.Command, + args []string, + additionalArgsAndFlags []string, + ) (schema.ConfigAndStacksInfo, error) + initCliConfig func(configAndStacksInfo schema.ConfigAndStacksInfo, processStacks bool) (schema.AtmosConfiguration, error) + validateStacks func(atmosConfig *schema.AtmosConfiguration) error + newDescribeLocalsExecFactory func() exec.DescribeLocalsExec +} + +func getRunnableDescribeLocalsCmd( + g getRunnableDescribeLocalsCmdProps, +) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + defer perf.Track(nil, "cmd.describeLocals")() + + // Fail fast: check --stack before expensive init operations. + // Cobra has already parsed flags by the time RunE is called. + stackFlag, _ := cmd.Flags().GetString(stackFlagName) + if stackFlag == "" { + return errUtils.ErrStackRequired + } + + // Check Atmos configuration. + g.checkAtmosConfig() + + // Pass args to ensure config-selection flags (--base-path, --config, etc.) are parsed. + // The component positional arg is extracted separately below. + info, err := g.processCommandLineArgs("", cmd, args, nil) + if err != nil { + return err + } + + atmosConfig, err := g.initCliConfig(info, true) + if err != nil { + return err + } + + err = g.validateStacks(&atmosConfig) + if err != nil { + return err + } + + describeArgs := &exec.DescribeLocalsArgs{} + + // Extract component from positional argument if provided. + if len(args) > 0 { + describeArgs.Component = args[0] + } + + err = setCliArgsForDescribeLocalsCli(cmd.Flags(), describeArgs) + if err != nil { + return err + } + + // Create executor lazily to avoid init-time side effects. + executor := g.newDescribeLocalsExecFactory() + err = executor.Execute(&atmosConfig, describeArgs) + return err + } +} + +func setCliArgsForDescribeLocalsCli(flags *pflag.FlagSet, args *exec.DescribeLocalsArgs) error { + var err error + + if flags.Changed(stackFlagName) { + args.FilterByStack, err = flags.GetString(stackFlagName) + if err != nil { + return fmt.Errorf("%w: read --stack: %w", errUtils.ErrInvalidFlag, err) + } + } + + if flags.Changed("format") { + args.Format, err = flags.GetString("format") + if err != nil { + return fmt.Errorf("%w: read --format: %w", errUtils.ErrInvalidFlag, err) + } + } + + if flags.Changed("file") { + args.File, err = flags.GetString("file") + if err != nil { + return fmt.Errorf("%w: read --file: %w", errUtils.ErrInvalidFlag, err) + } + } + + if flags.Changed("query") { + args.Query, err = flags.GetString("query") + if err != nil { + return fmt.Errorf("%w: read --query: %w", errUtils.ErrInvalidFlag, err) + } + } + + // Set default format. + if args.Format == "" { + args.Format = "yaml" + } + + return nil +} + +func init() { + // Use Flags() instead of PersistentFlags() since this command has no subcommands. + describeLocalsCmd.Flags().StringP(stackFlagName, "s", "", + "Stack to display locals for (required)\n"+ + "Supports top-level stack manifest names (including subfolder paths) and logical stack names (derived from context vars)\n"+ + "Parse errors in the matched stack are reported; errors in other stacks are skipped during the search", + ) + AddStackCompletion(describeLocalsCmd) + + describeLocalsCmd.Flags().StringP("format", "f", "yaml", "Specify the output format (`yaml` is default)") + + describeLocalsCmd.Flags().String("file", "", "Write the result to file") + + describeLocalsCmd.Flags().StringP("query", "q", "", "Query the result using `yq` expression") + + describeCmd.AddCommand(describeLocalsCmd) +} diff --git a/cmd/describe_locals_test.go b/cmd/describe_locals_test.go new file mode 100644 index 0000000000..6c42058f99 --- /dev/null +++ b/cmd/describe_locals_test.go @@ -0,0 +1,408 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/internal/exec" + "github.com/cloudposse/atmos/pkg/schema" +) + +func TestGetRunnableDescribeLocalsCmd(t *testing.T) { + t.Run("successful execution with stack", func(t *testing.T) { + ctrl := gomock.NewController(t) + + checkAtmosConfigCalled := false + processCommandLineArgsCalled := false + initCliConfigCalled := false + validateStacksCalled := false + + mockExec := exec.NewMockDescribeLocalsExec(ctrl) + mockExec.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + Return(nil) + + props := getRunnableDescribeLocalsCmdProps{ + checkAtmosConfig: func(opts ...AtmosValidateOption) { + checkAtmosConfigCalled = true + }, + processCommandLineArgs: func(componentType string, cmd *cobra.Command, args []string, additionalArgsAndFlags []string) (schema.ConfigAndStacksInfo, error) { + processCommandLineArgsCalled = true + return schema.ConfigAndStacksInfo{}, nil + }, + initCliConfig: func(configAndStacksInfo schema.ConfigAndStacksInfo, processStacks bool) (schema.AtmosConfiguration, error) { + initCliConfigCalled = true + return schema.AtmosConfiguration{}, nil + }, + validateStacks: func(atmosConfig *schema.AtmosConfiguration) error { + validateStacksCalled = true + return nil + }, + newDescribeLocalsExecFactory: func() exec.DescribeLocalsExec { return mockExec }, + } + + runFunc := getRunnableDescribeLocalsCmd(props) + cmd := &cobra.Command{} + cmd.Flags().String("stack", "", "") + cmd.Flags().String("format", "", "") + cmd.Flags().String("file", "", "") + cmd.Flags().String("query", "", "") + + // Stack is required. + require.NoError(t, cmd.Flags().Set("stack", "dev")) + + err := runFunc(cmd, []string{}) + require.NoError(t, err) + + assert.True(t, checkAtmosConfigCalled, "checkAtmosConfig should be called") + assert.True(t, processCommandLineArgsCalled, "processCommandLineArgs should be called") + assert.True(t, initCliConfigCalled, "initCliConfig should be called") + assert.True(t, validateStacksCalled, "validateStacks should be called") + }) + + t.Run("successful execution with component argument", func(t *testing.T) { + ctrl := gomock.NewController(t) + + var capturedArgs *exec.DescribeLocalsArgs + + mockExec := exec.NewMockDescribeLocalsExec(ctrl) + mockExec.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ *schema.AtmosConfiguration, args *exec.DescribeLocalsArgs) error { + capturedArgs = args + return nil + }) + + props := getRunnableDescribeLocalsCmdProps{ + checkAtmosConfig: func(opts ...AtmosValidateOption) {}, + processCommandLineArgs: func(componentType string, cmd *cobra.Command, args []string, additionalArgsAndFlags []string) (schema.ConfigAndStacksInfo, error) { + return schema.ConfigAndStacksInfo{}, nil + }, + initCliConfig: func(configAndStacksInfo schema.ConfigAndStacksInfo, processStacks bool) (schema.AtmosConfiguration, error) { + return schema.AtmosConfiguration{}, nil + }, + validateStacks: func(atmosConfig *schema.AtmosConfiguration) error { + return nil + }, + newDescribeLocalsExecFactory: func() exec.DescribeLocalsExec { return mockExec }, + } + + runFunc := getRunnableDescribeLocalsCmd(props) + cmd := &cobra.Command{} + cmd.Flags().String("stack", "", "") + cmd.Flags().String("format", "", "") + cmd.Flags().String("file", "", "") + cmd.Flags().String("query", "", "") + + // Set stack flag (required when component is specified). + require.NoError(t, cmd.Flags().Set("stack", "prod")) + + // Pass component as positional argument. + err := runFunc(cmd, []string{"vpc"}) + require.NoError(t, err) + + require.NotNil(t, capturedArgs) + assert.Equal(t, "vpc", capturedArgs.Component) + assert.Equal(t, "prod", capturedArgs.FilterByStack) + }) + + t.Run("missing stack returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockExec := exec.NewMockDescribeLocalsExec(ctrl) + // No expectation set - Execute should not be called. + + // Track if expensive operations are called (they should NOT be). + checkAtmosConfigCalled := false + processCommandLineArgsCalled := false + initCliConfigCalled := false + validateStacksCalled := false + + props := getRunnableDescribeLocalsCmdProps{ + checkAtmosConfig: func(opts ...AtmosValidateOption) { + checkAtmosConfigCalled = true + }, + processCommandLineArgs: func(componentType string, cmd *cobra.Command, args []string, additionalArgsAndFlags []string) (schema.ConfigAndStacksInfo, error) { + processCommandLineArgsCalled = true + return schema.ConfigAndStacksInfo{}, nil + }, + initCliConfig: func(configAndStacksInfo schema.ConfigAndStacksInfo, processStacks bool) (schema.AtmosConfiguration, error) { + initCliConfigCalled = true + return schema.AtmosConfiguration{}, nil + }, + validateStacks: func(atmosConfig *schema.AtmosConfiguration) error { + validateStacksCalled = true + return nil + }, + newDescribeLocalsExecFactory: func() exec.DescribeLocalsExec { return mockExec }, + } + + runFunc := getRunnableDescribeLocalsCmd(props) + cmd := &cobra.Command{} + cmd.Flags().String("stack", "", "") + cmd.Flags().String("format", "", "") + cmd.Flags().String("file", "", "") + cmd.Flags().String("query", "", "") + + // Call without --stack flag (required). + err := runFunc(cmd, []string{}) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrStackRequired) + + // Verify fail-fast: expensive operations should NOT be called. + assert.False(t, checkAtmosConfigCalled, "checkAtmosConfig should not be called when --stack is missing") + assert.False(t, processCommandLineArgsCalled, "processCommandLineArgs should not be called when --stack is missing") + assert.False(t, initCliConfigCalled, "initCliConfig should not be called when --stack is missing") + assert.False(t, validateStacksCalled, "validateStacks should not be called when --stack is missing") + }) + + t.Run("missing stack with component returns error", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockExec := exec.NewMockDescribeLocalsExec(ctrl) + // No expectation set - Execute should not be called. + + // Track if expensive operations are called (they should NOT be). + checkAtmosConfigCalled := false + processCommandLineArgsCalled := false + initCliConfigCalled := false + validateStacksCalled := false + + props := getRunnableDescribeLocalsCmdProps{ + checkAtmosConfig: func(opts ...AtmosValidateOption) { + checkAtmosConfigCalled = true + }, + processCommandLineArgs: func(componentType string, cmd *cobra.Command, args []string, additionalArgsAndFlags []string) (schema.ConfigAndStacksInfo, error) { + processCommandLineArgsCalled = true + return schema.ConfigAndStacksInfo{}, nil + }, + initCliConfig: func(configAndStacksInfo schema.ConfigAndStacksInfo, processStacks bool) (schema.AtmosConfiguration, error) { + initCliConfigCalled = true + return schema.AtmosConfiguration{}, nil + }, + validateStacks: func(atmosConfig *schema.AtmosConfiguration) error { + validateStacksCalled = true + return nil + }, + newDescribeLocalsExecFactory: func() exec.DescribeLocalsExec { return mockExec }, + } + + runFunc := getRunnableDescribeLocalsCmd(props) + cmd := &cobra.Command{} + cmd.Flags().String("stack", "", "") + cmd.Flags().String("format", "", "") + cmd.Flags().String("file", "", "") + cmd.Flags().String("query", "", "") + + // Pass component without --stack flag. + err := runFunc(cmd, []string{"vpc"}) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrStackRequired) + + // Verify fail-fast: expensive operations should NOT be called. + assert.False(t, checkAtmosConfigCalled, "checkAtmosConfig should not be called when --stack is missing") + assert.False(t, processCommandLineArgsCalled, "processCommandLineArgs should not be called when --stack is missing") + assert.False(t, initCliConfigCalled, "initCliConfig should not be called when --stack is missing") + assert.False(t, validateStacksCalled, "validateStacks should not be called when --stack is missing") + }) + + // Table-driven tests for error cases to avoid code duplication. + errorTests := []struct { + name string + processCommandLineErr error + initCliConfigErr error + validateStacksErr error + }{ + { + name: "processCommandLineArgs error", + processCommandLineErr: errors.New("process error"), + }, + { + name: "initCliConfig error", + initCliConfigErr: errors.New("init config error"), + }, + { + name: "validateStacks error", + validateStacksErr: errors.New("validate stacks error"), + }, + } + + for _, tt := range errorTests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + mockExec := exec.NewMockDescribeLocalsExec(ctrl) + // No expectation set - Execute should not be called for these error cases. + + props := getRunnableDescribeLocalsCmdProps{ + checkAtmosConfig: func(opts ...AtmosValidateOption) {}, + processCommandLineArgs: func(componentType string, cmd *cobra.Command, args []string, additionalArgsAndFlags []string) (schema.ConfigAndStacksInfo, error) { + return schema.ConfigAndStacksInfo{}, tt.processCommandLineErr + }, + initCliConfig: func(configAndStacksInfo schema.ConfigAndStacksInfo, processStacks bool) (schema.AtmosConfiguration, error) { + return schema.AtmosConfiguration{}, tt.initCliConfigErr + }, + validateStacks: func(atmosConfig *schema.AtmosConfiguration) error { + return tt.validateStacksErr + }, + newDescribeLocalsExecFactory: func() exec.DescribeLocalsExec { return mockExec }, + } + + runFunc := getRunnableDescribeLocalsCmd(props) + cmd := &cobra.Command{} + cmd.Flags().String("stack", "", "") + cmd.Flags().String("format", "", "") + cmd.Flags().String("file", "", "") + cmd.Flags().String("query", "", "") + + // Set --stack flag to pass fail-fast check and reach the error cases. + require.NoError(t, cmd.Flags().Set("stack", "dev")) + + err := runFunc(cmd, []string{}) + + // Determine which error to expect. + var expectedErr error + switch { + case tt.processCommandLineErr != nil: + expectedErr = tt.processCommandLineErr + case tt.initCliConfigErr != nil: + expectedErr = tt.initCliConfigErr + case tt.validateStacksErr != nil: + expectedErr = tt.validateStacksErr + } + + assert.ErrorIs(t, err, expectedErr) + }) + } + + t.Run("execute error", func(t *testing.T) { + ctrl := gomock.NewController(t) + + expectedErr := errors.New("execute error") + mockExec := exec.NewMockDescribeLocalsExec(ctrl) + mockExec.EXPECT(). + Execute(gomock.Any(), gomock.Any()). + Return(expectedErr) + + props := getRunnableDescribeLocalsCmdProps{ + checkAtmosConfig: func(opts ...AtmosValidateOption) {}, + processCommandLineArgs: func(componentType string, cmd *cobra.Command, args []string, additionalArgsAndFlags []string) (schema.ConfigAndStacksInfo, error) { + return schema.ConfigAndStacksInfo{}, nil + }, + initCliConfig: func(configAndStacksInfo schema.ConfigAndStacksInfo, processStacks bool) (schema.AtmosConfiguration, error) { + return schema.AtmosConfiguration{}, nil + }, + validateStacks: func(atmosConfig *schema.AtmosConfiguration) error { + return nil + }, + newDescribeLocalsExecFactory: func() exec.DescribeLocalsExec { return mockExec }, + } + + runFunc := getRunnableDescribeLocalsCmd(props) + cmd := &cobra.Command{} + cmd.Flags().String("stack", "", "") + cmd.Flags().String("format", "", "") + cmd.Flags().String("file", "", "") + cmd.Flags().String("query", "", "") + + // Stack is required. + require.NoError(t, cmd.Flags().Set("stack", "dev")) + + err := runFunc(cmd, []string{}) + assert.ErrorIs(t, err, expectedErr) + }) +} + +func TestSetCliArgsForDescribeLocalsCli(t *testing.T) { + t.Run("all flags set", func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("stack", "", "") + cmd.Flags().String("format", "", "") + cmd.Flags().String("file", "", "") + cmd.Flags().String("query", "", "") + + err := cmd.Flags().Set("stack", "my-stack") + require.NoError(t, err) + err = cmd.Flags().Set("format", "json") + require.NoError(t, err) + err = cmd.Flags().Set("file", "output.json") + require.NoError(t, err) + err = cmd.Flags().Set("query", ".dev") + require.NoError(t, err) + + args := &exec.DescribeLocalsArgs{} + err = setCliArgsForDescribeLocalsCli(cmd.Flags(), args) + require.NoError(t, err) + + assert.Equal(t, "my-stack", args.FilterByStack) + assert.Equal(t, "json", args.Format) + assert.Equal(t, "output.json", args.File) + assert.Equal(t, ".dev", args.Query) + }) + + t.Run("no flags set uses defaults", func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("stack", "", "") + cmd.Flags().String("format", "", "") + cmd.Flags().String("file", "", "") + cmd.Flags().String("query", "", "") + + args := &exec.DescribeLocalsArgs{} + err := setCliArgsForDescribeLocalsCli(cmd.Flags(), args) + require.NoError(t, err) + + assert.Empty(t, args.FilterByStack) + assert.Equal(t, "yaml", args.Format) + assert.Empty(t, args.File) + assert.Empty(t, args.Query) + }) + + t.Run("only stack flag set", func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("stack", "", "") + cmd.Flags().String("format", "", "") + cmd.Flags().String("file", "", "") + cmd.Flags().String("query", "", "") + + err := cmd.Flags().Set("stack", "deploy/dev") + require.NoError(t, err) + + args := &exec.DescribeLocalsArgs{} + err = setCliArgsForDescribeLocalsCli(cmd.Flags(), args) + require.NoError(t, err) + + assert.Equal(t, "deploy/dev", args.FilterByStack) + assert.Equal(t, "yaml", args.Format) + }) +} + +func TestDescribeLocalsCmd(t *testing.T) { + _ = NewTestKit(t) + + t.Run("command has expected properties", func(t *testing.T) { + assert.Equal(t, "locals [component] -s ", describeLocalsCmd.Use) + assert.NotEmpty(t, describeLocalsCmd.Short) + assert.NotEmpty(t, describeLocalsCmd.Long) + assert.NotEmpty(t, describeLocalsCmd.Example) + }) + + t.Run("command has expected flags", func(t *testing.T) { + stackFlag := describeLocalsCmd.Flag("stack") + assert.NotNil(t, stackFlag, "stack flag should exist") + assert.Equal(t, "s", stackFlag.Shorthand) + + formatFlag := describeLocalsCmd.Flag("format") + assert.NotNil(t, formatFlag, "format flag should exist") + assert.Equal(t, "f", formatFlag.Shorthand) + + fileFlag := describeLocalsCmd.Flag("file") + assert.NotNil(t, fileFlag, "file flag should exist") + + queryFlag := describeLocalsCmd.Flag("query") + assert.NotNil(t, queryFlag, "query flag should exist") + assert.Equal(t, "q", queryFlag.Shorthand) + }) +} diff --git a/docs/fixes/file-scoped-locals-not-working.md b/docs/fixes/file-scoped-locals-not-working.md new file mode 100644 index 0000000000..552fe91d5f --- /dev/null +++ b/docs/fixes/file-scoped-locals-not-working.md @@ -0,0 +1,829 @@ +# Issue #1933: File-Scoped Locals Not Working + +## Summary + +File-scoped locals feature documented in v1.203.0 release notes and blog post was not functional. The feature code +existed but was not integrated into the stack processing pipeline, causing `{{ .locals.* }}` templates to remain +unresolved. + +**Status**: ✅ Fixed + +## Issue Description + +**GitHub Issue**: [#1933](https://github.com/cloudposse/atmos/issues/1933) + +**Symptoms**: + +1. **Locals templates were not resolved**: + ```text + $ atmos describe component myapp -s prod + vars: + name: "{{ .locals.name_prefix }}-myapp" + stage: "{{ .locals.stage }}" + ``` + Templates showed raw `{{ .locals.* }}` instead of resolved values. + +**Example Configuration** (from user's report): + +```yaml +# stacks/myapp.yaml +locals: + stage: prod + name_prefix: "acme-{{ .locals.stage }}" + +components: + terraform: + myapp: + vars: + name: "{{ .locals.name_prefix }}-myapp" + stage: "{{ .locals.stage }}" +``` + +**Expected Behavior**: + +```yaml +# After resolution +components: + terraform: + myapp: + vars: + name: "acme-prod-myapp" + stage: "prod" +``` + +## Root Cause Analysis + +The file-scoped locals feature was documented in the v1.203.0 blog post (`website/blog/2025-12-16-file-scoped-locals.mdx`) +but the implementation was incomplete: + +### What Existed (Before Fix) + +| Component | Location | Status | +|-----------|----------|--------| +| Locals resolver with cycle detection | `pkg/locals/resolver.go` | ✅ Complete | +| Stack locals extraction functions | `internal/exec/stack_processor_locals.go` | ✅ Complete | +| Unit tests for locals resolution | `internal/exec/stack_processor_locals_test.go` | ✅ Complete | +| Template AST utilities for `.locals.*` | `pkg/template/ast.go` | ✅ Complete | + +### What Was Missing (Before Fix) + +| Component | Status | +|-----------|--------| +| Integration of `ProcessStackLocals()` into stack processing pipeline | ❌ Not called | +| `.locals` context in template execution | ❌ Not provided | +| Integration tests with stack fixtures | ❌ Not present | + +### Specific Code Gap + +In `processYAMLConfigFileWithContextInternal()` (`internal/exec/stack_processor_utils.go`): +- Template processing happened at line ~427 via `ProcessTmpl()` +- The `context` parameter passed to `ProcessTmpl` did NOT include `.locals` +- Locals were never extracted from the raw YAML before template processing + +## Solution Implemented + +### 1. Added Locals Extraction Before Template Processing + +Added `extractLocalsFromRawYAML()` function in `internal/exec/stack_processor_utils.go`: + +```go +// extractLocalsFromRawYAML parses raw YAML content and extracts/resolves file-scoped locals. +// This function is called BEFORE template processing to make locals available during template execution. +func extractLocalsFromRawYAML(atmosConfig *schema.AtmosConfiguration, yamlContent string, filePath string) (map[string]any, error) { + // Parse raw YAML to extract the structure. + // YAML treats template expressions like {{ .locals.X }} as plain strings, + // so parsing succeeds even with unresolved templates. + var rawConfig map[string]any + if err := yaml.Unmarshal([]byte(yamlContent), &rawConfig); err != nil { + return nil, fmt.Errorf("%w: failed to parse YAML for locals extraction: %w", + errUtils.ErrInvalidStackManifest, err) + } + + if rawConfig == nil { + return nil, nil + } + + // Use ProcessStackLocals which handles global and section-level scopes. + localsCtx, err := ProcessStackLocals(atmosConfig, rawConfig, filePath) + if err != nil { + return nil, err + } + + // Delegate flattening to LocalsContext which merges global and section-specific locals. + return localsCtx.MergeForTemplateContext(), nil +} +``` + +### 2. Integrated Into Stack Processing Pipeline + +In `processYAMLConfigFileWithContextInternal()`, added locals extraction before template processing: + +```go +// Extract and resolve file-scoped locals before template processing. +if !skipTemplatesProcessingInImports { + resolvedLocals, localsErr := extractLocalsFromRawYAML(atmosConfig, stackYamlConfig, filePath) + if localsErr != nil { + log.Trace("Failed to extract locals from file", "file", relativeFilePath, "error", localsErr) + // Non-fatal: continue without locals. + } else if resolvedLocals != nil && len(resolvedLocals) > 0 { + // Add resolved locals to the template context. + if context == nil { + context = make(map[string]any) + } + context["locals"] = resolvedLocals + } +} +``` + +### 3. Added Section Override Tracking + +During testing, a bug was discovered: when sections don't define their own locals, `ProcessStackLocals` set them +to the same reference as Global. This caused helmfile/packer to overwrite terraform's values during merging. + +**Fix**: Added tracking flags to `LocalsContext` in `internal/exec/stack_processor_locals.go`: + +```go +type LocalsContext struct { + Global map[string]any + Terraform map[string]any + Helmfile map[string]any + Packer map[string]any + + // HasTerraformLocals indicates the terraform section has its own locals defined. + HasTerraformLocals bool + + // HasHelmfileLocals indicates the helmfile section has its own locals defined. + HasHelmfileLocals bool + + // HasPackerLocals indicates the packer section has its own locals defined. + HasPackerLocals bool +} +``` + +These flags are set in `ProcessStackLocals()` when a section explicitly defines a `locals:` key: + +```go +if terraformSection, ok := stackConfigMap[cfg.TerraformSectionName].(map[string]any); ok { + terraformLocals, err := ExtractAndResolveLocals(atmosConfig, terraformSection, ctx.Global, filePath) + if err != nil { + return nil, fmt.Errorf("failed to resolve terraform locals: %w", err) + } + ctx.Terraform = terraformLocals + // Check if terraform section has its own locals key. + if _, hasLocals := terraformSection[cfg.LocalsSectionName]; hasLocals { + ctx.HasTerraformLocals = true + } +} +``` + +### 4. How It Works + +1. **Before template processing**: Raw YAML is parsed to extract `locals:` sections +2. **Locals resolution**: `ProcessStackLocals()` resolves locals using the existing resolver which handles: + - Self-referencing locals (e.g., `name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}"`) + - Dependency ordering (topological sort) + - Cycle detection +3. **Section tracking**: Flags track which sections explicitly define locals +4. **Template context**: Resolved locals are merged (global first, then sections with explicit locals) and added to context +5. **Error handling**: Circular dependencies are logged at TRACE level; processing continues without locals + +### 5. Scope Support + +Locals are extracted and merged in order of specificity: +1. **Global locals** (root level `locals:` section) +2. **Section-specific locals** (`terraform:`, `helmfile:`, `packer:` sections) - only if explicitly defined + +Section locals can override global locals with the same key. + +**Note**: Component-level locals are resolved after file-scoped locals and can reference both global and section-specific locals. They also support inheritance from base components via `metadata.inherits` or `component` attribute. + +## Design Intent: File-Scoped Isolation + +This section documents the original design intent for `locals` based on the PRD (`docs/prd/file-scoped-locals.md`) and blog post (`website/blog/2025-12-16-file-scoped-locals.mdx`). + +### Core Principle + +**Locals are strictly file-scoped and do NOT inherit across file boundaries via imports.** + +This is explicitly stated in the PRD: +> "Is **file-scoped only** (never inherited across file boundaries via imports)" + +### Two Types of Scope Merging + +**1. WITHIN a single file - Locals DO cascade (global → component-type only):** + +```text +global → component-type (terraform/helmfile/packer) +``` + +**Note:** Component-level locals (inside individual component definitions) ARE supported and can inherit from base components via `metadata.inherits` or `component` attribute. + +Example: +```yaml +# All in the SAME file +locals: + namespace: acme # Global scope + +terraform: + locals: + bucket: "{{ .locals.namespace }}-tfstate" # Inherits from global + +components: + terraform: + vpc: + vars: + # Uses merged locals (global + terraform section) + name: "{{ .locals.namespace }}-vpc" + bucket: "{{ .locals.bucket }}" +``` + +**2. ACROSS files via imports - Locals do NOT inherit:** + +```yaml +# _defaults.yaml +locals: + shared_value: "from-defaults" # Only available in THIS file + +# prod.yaml +import: + - _defaults + +locals: + prod_value: "prod-specific" + +components: + terraform: + vpc: + vars: + # ✅ Works - prod_value is defined in this file + name: "{{ .locals.prod_value }}" + + # ❌ Error - shared_value is NOT available (file-scoped to _defaults.yaml) + # bad_ref: "{{ .locals.shared_value }}" +``` + +### Comparison with vars and settings + +| Feature | Locals | Vars | Settings | +|---------|--------|------|----------| +| Inherited across imports | ❌ No | ✅ Yes | ✅ Yes | +| Passed to Terraform/Helmfile | ❌ No | ✅ Yes | ❌ No | +| Visible in `describe component` | ❌ No | ✅ Yes | ✅ Yes | +| Available in templates within same file | ✅ Yes | ✅ Yes | ✅ Yes | +| Purpose | File-scoped temp variables | Tool inputs | Component metadata | + +### Design Rationale + +From the blog post "Why File-Scoped?": + +1. **Predictability** - You know exactly what locals are available by looking at the current file +2. **No hidden dependencies** - Locals won't mysteriously change based on import order +3. **Safer refactoring** - Renaming a local in one file won't break other files +4. **Clear separation** - Use `vars` for values that should propagate; use `locals` for file-internal convenience + +### Test Fixtures Alignment + +All test fixtures correctly implement this design: + +| Fixture | Tests | Alignment | +|---------|-------|-----------| +| `locals` | Basic locals with global + terraform scopes | ✅ Within-file scoping | +| `locals-file-scoped` | File's own locals work, mixin's don't | ✅ Cross-file isolation | +| `locals-not-inherited` | Mixin locals NOT available to importer | ✅ Cross-file isolation | +| `locals-deep-import-chain` | 4-level chain, each file has own locals | ✅ Cross-file isolation | +| `locals-logical-names` | terraform + helmfile section locals | ✅ Within-file scoping | +| `locals-circular` | Circular dependency detection | ✅ Within-file validation | + +## Files Changed + +| File | Change | +|------|--------| +| `internal/exec/stack_processor_utils.go` | Added `extractLocalsFromRawYAML()` function and integration | +| `internal/exec/stack_processor_locals.go` | Added `HasTerraformLocals`, `HasHelmfileLocals`, `HasPackerLocals` flags to `LocalsContext` | +| `internal/exec/stack_processor_utils_test.go` | Added 16 unit tests for `extractLocalsFromRawYAML` | +| `internal/exec/stack_processor_locals_test.go` | Added 10 unit tests for file-scoped locals behavior | +| `internal/exec/describe_locals.go` | New file: business logic for `describe locals` command | +| `internal/exec/describe_locals_test.go` | New file: unit tests for describe locals | +| `cmd/describe_locals.go` | New file: CLI command for `describe locals` | +| `tests/fixtures/scenarios/locals/stacks/deploy/dev.yaml` | Updated to use global/section-level locals | +| `tests/fixtures/scenarios/locals/stacks/deploy/prod.yaml` | Updated to use global/section-level locals | +| `tests/fixtures/scenarios/locals-deep-import-chain/` | New fixture: 4-level import chain for testing file-scoped isolation | +| `tests/cli_locals_test.go` | Integration tests (14 total) including deep import chain tests | +| `website/docs/cli/commands/describe/describe-locals.mdx` | New documentation for `describe locals` command | +| `website/blog/2026-01-06-file-scoped-locals-fix.mdx` | Blog post announcing the fix | + +## Testing + +### Test Coverage + +| Function | Coverage | +|----------|----------| +| `extractLocalsFromRawYAML` | **95.8%** | +| `ExtractAndResolveLocals` | **100%** | +| `ProcessStackLocals` | **100%** | +| `pkg/locals` (resolver) | **94.7%** | + +### Unit Tests + +#### `internal/exec/stack_processor_utils_test.go` (16 tests) + +1. `TestExtractLocalsFromRawYAML_Basic` - Basic locals extraction +2. `TestExtractLocalsFromRawYAML_NoLocals` - No locals section +3. `TestExtractLocalsFromRawYAML_EmptyYAML` - Empty YAML content +4. `TestExtractLocalsFromRawYAML_InvalidYAML` - Malformed YAML +5. `TestExtractLocalsFromRawYAML_TerraformSectionLocals` - Terraform section locals +6. `TestExtractLocalsFromRawYAML_HelmfileSectionLocals` - Helmfile section locals +7. `TestExtractLocalsFromRawYAML_PackerSectionLocals` - Packer section locals +8. `TestExtractLocalsFromRawYAML_AllSectionLocals` - All sections with locals +9. `TestExtractLocalsFromRawYAML_CircularDependency` - Circular dependency detection +10. `TestExtractLocalsFromRawYAML_SelfReference` - Self-referencing locals +11. `TestExtractLocalsFromRawYAML_ComplexValue` - Complex values (maps) +12. `TestExtractLocalsFromRawYAML_SectionOverridesGlobal` - Section overrides global +13. `TestExtractLocalsFromRawYAML_TemplateInNonLocalSection` - Template in vars section +14. `TestExtractLocalsFromRawYAML_NilAtmosConfig` - Nil AtmosConfiguration +15. `TestExtractLocalsFromRawYAML_OnlyComments` - YAML with only comments +16. `TestExtractLocalsFromRawYAML_EmptyLocals` - Empty locals section + +#### `internal/exec/stack_processor_locals_test.go` (10 additional tests) + +Tests for file-scoped locals behavior: + +1. `TestLocalsContext_MergeForTemplateContext` - Verifies merge behavior (global → terraform → helmfile → packer) +2. `TestLocalsContext_MergeForTemplateContext_OnlyGlobal` - Global-only merge scenario +3. `TestLocalsContext_MergeForTemplateContext_Nil` - Nil LocalsContext handling +4. `TestLocalsContext_MergeForTemplateContext_EmptyGlobal` - Empty global locals handling +5. `TestProcessStackLocals_SectionLocalsOverrideGlobal` - Section overrides global values +6. `TestProcessStackLocals_HasFlagsSetCorrectly` - Table-driven test (5 sub-tests) for Has*Locals flags +7. `TestExtractAndResolveLocals_NestedTemplateReferences` - Deeply nested template references +8. `TestExtractAndResolveLocals_MixedStaticAndTemplateValues` - Mixed static and template values +9. `TestExtractAndResolveLocals_ParentLocalsNotModified` - Verifies parent locals immutability +10. `TestProcessStackLocals_IsolationBetweenSections` - Verifies section isolation + +### Integration Tests + +Tests in `tests/cli_locals_test.go` (12 tests total): + +#### Basic Resolution Tests +- `TestLocalsResolutionDev` - Verifies locals resolution in dev environment +- `TestLocalsResolutionProd` - Verifies locals resolution in prod environment +- `TestLocalsDescribeStacks` - Verifies describe stacks works with locals +- `TestLocalsCircularDependency` - Verifies circular dependencies are detected gracefully + +#### File-Scoped Behavior Tests +- `TestLocalsFileScoped` - Verifies file's own locals resolve correctly +- `TestLocalsNotInherited` - Verifies mixin locals are NOT inherited (file-scoped) +- `TestLocalsNotInFinalOutput` - Verifies locals are stripped from final output + +#### Describe Locals Command Tests +- `TestDescribeLocals` - Tests describe locals extracts and displays correctly +- `TestDescribeLocalsWithFilter` - Tests describe locals with stack filter + +#### Deep Import Chain Tests +- `TestLocalsDeepImportChain` - Tests 4-level import chain (base → layer1 → layer2 → final) +- `TestLocalsDeepImportChainDescribeStacks` - Tests describe stacks with deep import chains +- `TestLocalsDescribeLocalsDeepChain` - Tests describe locals shows each file's locals independently + +### Test Fixtures + +#### `tests/fixtures/scenarios/locals-deep-import-chain/` + +New fixture for testing file-scoped locals across deep import chains: + +```text +locals-deep-import-chain/ +├── atmos.yaml +└── stacks/ + ├── deploy/ + │ └── final.yaml # Level 4: imports layer2, defines own locals + └── mixins/ + ├── base.yaml # Level 1: defines base_local, shared_key + ├── layer1.yaml # Level 2: imports base, defines layer1_local + └── layer2.yaml # Level 3: imports layer1, defines layer2_local +``` + +This fixture validates: +1. Each file can only access its own locals (file-scoped) +2. Locals are NOT inherited through import chains +3. Regular vars ARE inherited (normal Atmos behavior) +4. Nested template references work within a file +5. The `shared_key` at each level has different values, proving isolation + +### Manual Testing + +#### Basic Locals Resolution + +```bash +cd tests/fixtures/scenarios/locals +../../../../build/atmos describe stacks --format yaml +``` + +Output shows resolved locals: +```yaml +dev-us-east-1: + components: + terraform: + mock/instance-1: + backend: + bucket: acme-dev-tfstate # Resolved from {{ .locals.backend_bucket }} + vars: + app_name: acme-dev-mock-instance-1 # Resolved from {{ .locals.name_prefix }}-mock-instance-1 + bar: dev # Resolved from {{ .locals.environment }} +``` + +#### Deep Import Chain Testing + +```bash +cd tests/fixtures/scenarios/locals-deep-import-chain +../../../../build/atmos describe component deep-chain-component -s final --format yaml +``` + +Output shows file-scoped locals and inherited vars: +```yaml +vars: + # File's own locals resolved correctly + local_value: from-final-stack + computed: from-final-stack-computed + shared: final-value + full_chain: final-value-from-final-stack + # Regular vars inherited from all levels + base_var: from-base-vars + layer1_var: from-layer1-vars + layer2_var: from-layer2-vars + final_var: from-final-vars +``` + +### Test Summary + +| Category | Count | +|----------|-------| +| Unit tests (`stack_processor_utils_test.go`) | 16 | +| Unit tests (`stack_processor_locals_test.go`) | 41 (including 10 new) | +| Unit tests (`describe_locals_test.go`) | 2 | +| Integration tests (`cli_locals_test.go`) | 12 | +| **Total** | **71** | + +## Bug Found During Testing + +### Section Override Bug + +**Problem**: When a section (terraform/helmfile/packer) doesn't define its own locals, `ProcessStackLocals` was +setting that section's locals to the same reference as Global. During merging in `extractLocalsFromRawYAML`, +helmfile/packer would overwrite terraform's values with global values because they were merged after terraform. + +**Example**: +```yaml +locals: + namespace: "global-acme" +terraform: + locals: + namespace: "terraform-acme" +# helmfile/packer have no locals section +``` + +Before fix: Result was `namespace: "global-acme"` (wrong - helmfile/packer overwrote terraform) +After fix: Result is `namespace: "terraform-acme"` (correct - only sections with explicit locals are merged) + +**Solution**: Added `HasTerraformLocals`, `HasHelmfileLocals`, `HasPackerLocals` boolean flags to track which +sections explicitly define their own locals. Only merge section locals when the corresponding flag is true. + +## Describe Locals Command + +The `atmos describe locals` command was added to help users inspect and debug their locals configuration. + +### Usage + +```bash +# Show locals for a specific stack (--stack is required) +atmos describe locals --stack deploy/dev + +# Show locals for a specific stack (using logical stack name) +atmos describe locals -s prod-us-east-1 + +# Show locals for a component in a stack +atmos describe locals vpc -s prod + +# Output as JSON +atmos describe locals -s deploy/dev --format json + +# Write to file +atmos describe locals -s deploy/dev --file locals.yaml +``` + +### Output Structure + +The command outputs locals using Atmos schema format: +- **locals**: Root-level locals defined in the stack file +- **terraform/helmfile/packer**: Section-specific locals nested under `{ locals: }` (if defined) + +For component queries, output uses schema format: `components: { terraform: { component-name: { locals: } } }` + +Example stack output (direct format - valid stack manifest YAML): +```yaml +locals: + environment: dev + namespace: acme + name_prefix: acme-dev +terraform: + locals: + backend_bucket: acme-dev-tfstate + tf_specific: terraform-only +``` + +Example component output: +```yaml +components: + terraform: + vpc: + locals: + environment: dev + namespace: acme + name_prefix: acme-dev + backend_bucket: acme-dev-tfstate + tf_specific: terraform-only +``` + +### Implementation + +- **Command**: `cmd/describe_locals.go` +- **Business logic**: `internal/exec/describe_locals.go` +- **Unit tests**: `internal/exec/describe_locals_test.go` +- **Integration tests**: `tests/cli_locals_test.go` (`TestDescribeLocals`, `TestDescribeLocalsWithFilter`) + +## Manual CLI Testing Guide + +This section provides comprehensive CLI commands to manually test all `locals` functionality using the test fixtures in `tests/fixtures/scenarios/`. + +### Test Fixtures Overview + +| Fixture | Purpose | Stack Names | +|---------|---------|-------------| +| `locals` | Basic locals with global + terraform scopes | `dev-us-east-1`, `prod-us-east-1` | +| `locals-logical-names` | Logical stack name derivation, terraform + helmfile | `dev-us-east-1`, `prod-us-west-2` | +| `locals-file-scoped` | File-scoped isolation from mixin imports | `test` | +| `locals-not-inherited` | Mixin locals NOT available to importer | `test` | +| `locals-deep-import-chain` | 4-level import chain isolation | `final` | +| `locals-circular` | Circular dependency detection | `dev` | + +### 1. Basic Locals Resolution + +Test that `{{ .locals.* }}` templates resolve correctly. + +```bash +# Navigate to basic locals fixture +cd tests/fixtures/scenarios/locals + +# Test describe stacks - shows all stacks with resolved locals +../../../../build/atmos describe stacks --format yaml + +# Test describe component - shows resolved vars for specific component +../../../../build/atmos describe component mock/instance-1 -s dev-us-east-1 + +# Expected: vars.app_name = "acme-dev-mock-instance-1" (resolved from {{ .locals.name_prefix }}) +# Expected: backend.bucket = "acme-dev-tfstate" (resolved from {{ .locals.backend_bucket }}) +``` + +### 2. Logical Stack Names vs File Paths + +The `--stack` flag accepts both logical stack names and file paths. + +```bash +# Navigate to logical names fixture +cd tests/fixtures/scenarios/locals-logical-names + +# Using LOGICAL stack name (derived from name_template) +../../../../build/atmos describe locals --stack dev-us-east-1 +../../../../build/atmos describe locals --stack prod-us-west-2 + +# Using FILE PATH (relative to stacks base) +../../../../build/atmos describe locals --stack deploy/dev +../../../../build/atmos describe locals --stack deploy/prod + +# Both should return the same locals for the same underlying file +# Verify: dev-us-east-1 == deploy/dev +# Verify: prod-us-west-2 == deploy/prod +``` + +### 3. Describe Locals Command + +Test all variations of the `describe locals` command. + +```bash +cd tests/fixtures/scenarios/locals-logical-names + +# Show locals for a stack (using logical name) +../../../../build/atmos describe locals --stack dev-us-east-1 + +# Show locals for a stack (using file path) +../../../../build/atmos describe locals --stack deploy/prod + +# With component argument - shows merged locals for component's type +../../../../build/atmos describe locals vpc -s dev-us-east-1 +# Expected output structure: { components: { terraform: { vpc: { locals } } } } + +# Helmfile component (tests helmfile section locals) +../../../../build/atmos describe locals nginx -s prod-us-west-2 +# Expected: { components: { helmfile: { nginx: { locals } } } } with helmfile-specific locals + +# Different output formats +../../../../build/atmos describe locals --stack dev-us-east-1 --format yaml +../../../../build/atmos describe locals --stack dev-us-east-1 --format json + +# Write to file +../../../../build/atmos describe locals --stack dev-us-east-1 --file /tmp/locals-output.yaml +cat /tmp/locals-output.yaml + +# With query (yq expression) +../../../../build/atmos describe locals --stack dev-us-east-1 --query '.locals.namespace' +# Expected: "acme" +``` + +### 4. File-Scoped Isolation + +Test that locals do NOT inherit across file boundaries via imports. + +```bash +# Test that file's own locals work +cd tests/fixtures/scenarios/locals-file-scoped +../../../../build/atmos describe component test-component -s test + +# Expected: vars.own_local = "file-ns-file-env" (from file's own {{ .locals.file_computed }}) + +# Describe locals should show ONLY the file's locals, NOT mixin's locals +../../../../build/atmos describe locals --stack test +# Expected: file_namespace, file_env, file_computed +# Should NOT include: mixin_namespace, mixin_env, mixin_computed +``` + +### 5. Locals NOT Inherited from Imports + +Test that attempting to use a mixin's local fails gracefully. + +```bash +cd tests/fixtures/scenarios/locals-not-inherited + +# This file tries to use {{ .locals.mixin_value }} from the mixin - should remain unresolved +../../../../build/atmos describe component test-component -s test + +# Expected: vars.attempt_mixin_local = "{{ .locals.mixin_value }}" (unresolved template) +# Regular vars ARE inherited: vars.inherited_var = "from-mixin-vars" +``` + +### 6. Deep Import Chain + +Test 4-level import chain: base → layer1 → layer2 → final. + +```bash +cd tests/fixtures/scenarios/locals-deep-import-chain + +# Describe the component - should show file's own locals resolved +../../../../build/atmos describe component deep-chain-component -s final + +# Expected resolved values (from final.yaml's own locals): +# vars.local_value = "from-final-stack" +# vars.computed = "from-final-stack-computed" +# vars.shared = "final-value" (NOT "base-value" or "layer1-value" or "layer2-value") +# vars.full_chain = "final-value-from-final-stack" + +# Expected inherited vars (regular vars DO inherit): +# vars.base_var = "from-base-vars" +# vars.layer1_var = "from-layer1-vars" +# vars.layer2_var = "from-layer2-vars" +# vars.final_var = "from-final-vars" + +# Describe locals for the deep chain stack +../../../../build/atmos describe locals --stack final + +# Expected: Only final.yaml's locals (final_local, shared_key, computed_value, full_chain) +# Should NOT include: base_local, layer1_local, layer2_local +``` + +### 7. Section-Specific Locals + +Test terraform, helmfile, and packer section locals. + +```bash +cd tests/fixtures/scenarios/locals-logical-names + +# Terraform component - gets global + terraform locals merged +../../../../build/atmos describe locals vpc -s prod-us-west-2 +# Expected: includes tf_only: "terraform-specific-prod" + +# Helmfile component - gets global + helmfile locals merged +../../../../build/atmos describe locals nginx -s prod-us-west-2 +# Expected: includes hf_only: "helmfile-specific-prod", release_name: "acme-prod-release" + +# Verify section isolation - terraform component should NOT have helmfile locals +../../../../build/atmos describe locals vpc -s prod-us-west-2 --format json | grep -c "hf_only" +# Expected: 0 (not found) +``` + +### 8. Circular Dependency Detection + +Test that circular dependencies are detected and handled gracefully. + +```bash +cd tests/fixtures/scenarios/locals-circular + +# This should detect circular dependency and continue without resolving locals +../../../../build/atmos describe stacks 2>&1 + +# The command should complete (not hang or crash) +# Locals with circular references remain unresolved +``` + +### 9. Describe Stacks with Locals + +Test that describe stacks works correctly with locals. + +```bash +cd tests/fixtures/scenarios/locals + +# Full describe stacks output +../../../../build/atmos describe stacks --format yaml + +# Verify locals are resolved in backend configuration +../../../../build/atmos describe stacks --format yaml | grep -A5 "backend:" +# Expected: bucket: "acme-dev-tfstate" (not {{ .locals.backend_bucket }}) + +# Verify locals are stripped from final component output +../../../../build/atmos describe stacks --format yaml | grep -c "^ locals:" +# Expected: 0 (locals section should not appear in component output) +``` + +### 10. Output Structure Verification + +Verify the output structure differences between stack and component queries. + +```bash +cd tests/fixtures/scenarios/locals-logical-names + +# Stack query - returns direct format (valid stack manifest YAML) +../../../../build/atmos describe locals --stack dev-us-east-1 --format json + +# Expected structure: +# { +# "locals": { ... }, +# "terraform": { +# "locals": { ... } +# } +# } + +# Component query - returns Atmos schema format +../../../../build/atmos describe locals vpc -s dev-us-east-1 --format json + +# Expected structure: +# { +# "components": { +# "terraform": { +# "vpc": { +# "locals": { ... } +# } +# } +# } +# } +``` + +### Quick Test Script + +Run this script to test all major functionality: + +```bash +#!/bin/bash +set -e + +ATMOS="../../../../build/atmos" + +echo "=== Test 1: Basic Locals Resolution ===" +cd tests/fixtures/scenarios/locals +$ATMOS describe component mock/instance-1 -s dev-us-east-1 --format yaml | grep "app_name" + +echo "=== Test 2: Logical Stack Name ===" +cd ../locals-logical-names +$ATMOS describe locals --stack dev-us-east-1 --format yaml | head -10 + +echo "=== Test 3: File Path Stack Name ===" +$ATMOS describe locals --stack deploy/dev --format yaml | head -10 + +echo "=== Test 4: Component Argument ===" +$ATMOS describe locals vpc -s dev-us-east-1 --format yaml + +echo "=== Test 5: File-Scoped Isolation ===" +cd ../locals-file-scoped +$ATMOS describe locals --stack test --format yaml + +echo "=== Test 6: Deep Import Chain ===" +cd ../locals-deep-import-chain +$ATMOS describe component deep-chain-component -s final --format yaml | grep -E "(local_value|shared|base_var)" + +echo "=== Test 7: Helmfile Section Locals ===" +cd ../locals-logical-names +$ATMOS describe locals nginx -s prod-us-west-2 --format yaml + +echo "=== All tests passed! ===" +``` + +## References + +- [File-Scoped Locals Blog Post](https://atmos.tools/changelog/file-scoped-locals/) +- [GitHub Issue #1933](https://github.com/cloudposse/atmos/issues/1933) +- [Atmos v1.203.0 Release Notes](https://github.com/cloudposse/atmos/releases/tag/v1.203.0) diff --git a/docs/prd/file-scoped-locals.md b/docs/prd/file-scoped-locals.md index 5b22e1c64c..ca23ae6e01 100644 --- a/docs/prd/file-scoped-locals.md +++ b/docs/prd/file-scoped-locals.md @@ -97,16 +97,15 @@ locals: terraform: locals: state_bucket: "terraform-state-{{ .locals.account_id }}" + vpc_name: "main-vpc-{{ .locals.region }}" vars: backend_bucket: "{{ .locals.state_bucket }}" -# Component-specific locals +# Components use merged locals (global + terraform section) components: terraform: vpc: - locals: - vpc_name: "main-vpc-{{ .locals.region }}" vars: name: "{{ .locals.vpc_name }}" tags: @@ -169,8 +168,8 @@ components: Within a single file, locals are resolved in this order (inner scopes can reference outer scopes): 1. **Global locals** → resolved first, available everywhere in the file -2. **Component-type locals** (terraform/helmfile) → can reference global locals -3. **Component locals** → can reference global and component-type locals +2. **Component-type locals** (terraform/helmfile/packer) → can reference global locals +3. **Component-level locals** → can reference global and component-type locals, and inherit from base components ```yaml locals: @@ -184,11 +183,66 @@ components: terraform: vpc: locals: - component_val: "{{ .locals.tf_val }}-vpc" + component_val: "{{ .locals.tf_val }}-vpc" # Results in "global-terraform-vpc" vars: - name: "{{ .locals.component_val }}" # Results in "global-terraform-vpc" + # Uses merged locals (global + terraform section + component) + name: "{{ .locals.component_val }}" +``` + +### Component-Level Locals Inheritance + +Unlike file-scoped locals (which do NOT inherit across file boundaries), component-level locals **do** inherit from base components via `component` attribute or `metadata.inherits`, similar to how `vars` work. + +```yaml +components: + terraform: + vpc/base: + metadata: + type: abstract + locals: + vpc_type: "standard" + cidr_prefix: "10.0" + vars: + name: "{{ .locals.vpc_type }}-vpc" + + vpc/prod: + metadata: + inherits: + - vpc/base + locals: + # Overrides base component's vpc_type + vpc_type: "production" + # Adds new local + environment: "prod" + vars: + # Uses inherited cidr_prefix from base, overridden vpc_type + cidr: "{{ .locals.cidr_prefix }}.0.0/16" + tags: + env: "{{ .locals.environment }}" ``` +**Inheritance hierarchy for component-level locals:** + +```text +Base Component Locals → Component Locals (component-level takes precedence) +``` + +**Full locals resolution order for a component:** + +```text +Global Locals → Section Locals → Base Component Locals → Component Locals +``` + +**Precedence rules for name conflicts (later overrides earlier):** +- Component locals override base component locals +- Base component locals override section-specific locals +- Section-specific locals override global locals + +This means a component can: +- Reference global and section-level locals defined in the same file +- Inherit locals from base components via `metadata.inherits` or `component` attribute +- Override inherited locals with its own definitions + ## Behavior Clarifications ### What Locals Are NOT @@ -252,27 +306,41 @@ locals: derived: "{{ .vars.base }}-extended" # Works if vars.base is static ``` -**Component inheritance (`metadata.inherits`)**: Locals are NOT inherited through component inheritance—they are purely file-scoped +**Component inheritance (`metadata.inherits`)**: File-scoped locals (global and section-level) are NOT inherited across file boundaries via imports. However, **component-level locals** DO inherit from base components via `metadata.inherits` or `component` attribute, similar to how `vars` work. ```yaml # catalog/base.yaml +locals: + base_local: "value" # File-scoped - only available in THIS file + components: terraform: base-vpc: locals: - base_local: "value" # NOT inherited + vpc_type: "standard" # Component-level - inherited by child components + vars: + name: "{{ .locals.base_local }}" # Works - same file -# deploy/prod.yaml +# deploy/prod.yaml (imports catalog/base.yaml) components: terraform: vpc: metadata: inherits: - base-vpc + locals: + # ✅ vpc_type is inherited from base-vpc component-level locals + vpc_type: "production" # Override inherited value vars: - # ❌ Error - base_local is not available (was in catalog/base.yaml) + # ❌ Error - base_local is not available (file-scoped to catalog/base.yaml) # name: "{{ .locals.base_local }}" + # ✅ Works - uses component-level locals + type: "{{ .locals.vpc_type }}" ``` +**Note:** Template syntax `{{ .locals.* }}` is the same for all local types. To determine if a local is file-scoped or component-level when debugging template failures: +- Check if it's defined under `components.terraform.COMPONENT.locals` (component-level, inheritable) +- Check if it's defined at root level or in terraform/helmfile/packer sections (file-scoped, not inheritable) + ## Implementation Considerations ### Processing Order @@ -309,7 +377,7 @@ Example: locals = {c: "{{.locals.b}}", a: "val", b: "{{.locals.a}}"} ### Cross-Scope Local References -Inner scopes inherit resolved locals from outer scopes: +Component-type scopes inherit resolved locals from global scope: ```yaml locals: @@ -322,14 +390,18 @@ terraform: components: terraform: vpc: - locals: - comp_val: "{{ .locals.tf_val }}-vpc" # Scope 3: can see global_val AND tf_val + vars: + # Components use merged locals (global + terraform) + name: "{{ .locals.tf_val }}-vpc" # Can see global_val AND tf_val ``` Resolution order: 1. Resolve global locals (scope 1) 2. Resolve terraform locals with global locals in context (scope 2) -3. Resolve component locals with global + terraform locals in context (scope 3) +3. Resolve component-level locals with global + section locals in context (scope 3) +4. Merge all scopes for use in component templates + +Component-level locals support inheritance from base components via `metadata.inherits` or `component` attribute. ### Template Context @@ -1344,12 +1416,11 @@ func TestLocalsResolver_ComplexTemplateExpressions(t *testing.T) { - Update documentation **Phase 7: Debugging Support** (~2-3 days) -- Implement `atmos describe locals` command with provenance support +- Implement `atmos describe locals` command - Follow command registry pattern from `cmd/internal/registry.go` - Create `cmd/describe/locals.go` implementing `CommandProvider` - Register via `internal.Register()` in `init()` - Use `pkg/flags/` for `--stack` and `--format` flags - - Show source file for each local (provenance tracking) - Show scope hierarchy (global → terraform → component) - Enhance error messages with available locals list - Add typo detection ("did you mean?") using Levenshtein distance @@ -1523,11 +1594,14 @@ tests/test-cases/locals/ ### Debugging Locals -Since locals are file-scoped and not visible in `atmos describe component`, we provide a dedicated `atmos describe locals` command with full provenance tracking: +Since locals are file-scoped and not visible in `atmos describe component`, we provide a dedicated `atmos describe locals` command: #### The `atmos describe locals` Command ```bash +# Show locals for a specific stack +atmos describe locals --stack prod-us-east-1 + # Show all locals for a component in a stack atmos describe locals vpc -s prod-us-east-1 @@ -1535,34 +1609,53 @@ atmos describe locals vpc -s prod-us-east-1 atmos describe locals vpc -s prod-us-east-1 --format json ``` -Output with provenance: -```yaml -# Locals for component "vpc" in stack "prod-us-east-1" -# Resolution order: global → terraform → component +**Note:** The `--stack` flag is required. Locals are file-scoped, so a specific stack must be specified. -global: # Source: stacks/catalog/vpc.yaml:3 - region: "us-east-1" +**Example output (stack query without component):** +```yaml +locals: + region: us-east-2 account_id: "123456789012" + environment: prod +terraform: + locals: + state_bucket: terraform-state-123456789012 + state_key_prefix: plat-ue2-prod +``` -terraform: # Source: stacks/catalog/vpc.yaml:15 - state_bucket: "terraform-state-123456789012" - -component: # Source: stacks/orgs/acme/plat/prod.yaml:42 - vpc_name: "main-vpc-us-east-1" +**Output format:** The output uses the same YAML structure as Atmos stack manifest files, meaning you can copy the output directly into a stack manifest. The structure matches the schema defined in `pkg/datafetcher/schema/stacks/`. -merged: # Final merged locals available to templates - region: "us-east-1" # from global (stacks/catalog/vpc.yaml:4) - account_id: "123456789012" # from global (stacks/catalog/vpc.yaml:5) - state_bucket: "terraform-state-123456789012" # from terraform (stacks/catalog/vpc.yaml:16) - vpc_name: "main-vpc-us-east-1" # from component (stacks/orgs/acme/plat/prod.yaml:43) +**Example output (component query):** +```yaml +components: + terraform: + vpc: + locals: + region: us-east-2 + account_id: "123456789012" + environment: prod + state_bucket: terraform-state-123456789012 + state_key_prefix: plat-ue2-prod ``` -#### Key Features +The component output also follows Atmos schema format, showing the merged locals that would be available to the component during template processing. + +#### Key Design Decisions -1. **Provenance tracking**: Shows which file and line number each local was defined -2. **Scope separation**: Clearly shows global, component-type, and component scopes -3. **Merged view**: Shows the final resolved locals available to templates -4. **Resolution tracing**: In the merged view, shows where each value originated +1. **Atmos schema format**: Output uses the same structure as stack manifest files +2. **Scope separation**: Stack output shows global `locals:` and section-specific locals separately +3. **Merged component view**: Component output shows all merged locals (global + section + component-level) +4. **JSON for tooling**: Full metadata in JSON format for programmatic access +5. **Consistent with other describe commands**: Same flags (`-s`, `--format`) as `atmos describe component` +6. **Stack name flexibility**: Accepts both logical stack names and file paths + +#### Implementation Notes + +The command follows the existing `describe` command pattern: +- Located in `cmd/describe_locals.go` +- Uses `CommandProvider` pattern from `cmd/internal/registry.go` +- Business logic in `internal/exec/describe_locals.go` +- Reuses `ProcessStackLocals()` from `internal/exec/stack_processor_locals.go` #### Optional: `ATMOS_DEBUG_LOCALS` Environment Variable @@ -1587,7 +1680,7 @@ Output (to stderr): [locals] Resolution complete: 4 locals available ``` -#### Option 4: Provenance in Error Messages +#### Option 4: Error Messages with Available Locals When a template fails, show which locals were available: @@ -1610,7 +1703,7 @@ Hint: Check for typos in local variable names. | Feature | Priority | Effort | Phase | |---------|----------|--------|-------| -| Provenance in error messages | P0 (must have) | Low | Phase 2 | +| Error messages with available locals | P0 (must have) | Low | Phase 2 | | `atmos describe locals` command | P1 (should have) | Medium | Phase 2 | | `ATMOS_DEBUG_LOCALS` env var | P2 (nice to have) | Low | Phase 3 | @@ -1619,116 +1712,6 @@ Hint: Check for typos in local variable names. - Typo detection with "did you mean?" suggestions - `atmos describe locals` command to inspect resolved locals -### The `atmos describe locals` Command - -List the resolved locals for a component in a stack with full provenance tracking: - -```bash -# Show locals for a component in a stack -atmos describe locals vpc -s plat-ue2-prod - -# Output as YAML (default) -atmos describe locals vpc -s plat-ue2-prod --format yaml - -# Output as JSON (includes full provenance metadata) -atmos describe locals vpc -s plat-ue2-prod --format json -``` - -**Example output (YAML):** -```yaml -# Locals for component 'vpc' in stack 'plat-ue2-prod' -# Resolution order: global → terraform → component - -# Global scope (stacks/catalog/vpc.yaml) -global: - region: us-east-2 # line 5 - account_id: "123456789012" # line 6 - environment: prod # line 7 - -# Terraform scope (stacks/catalog/vpc.yaml) -terraform: - state_bucket: terraform-state-123456789012 # line 12 - state_key_prefix: plat-ue2-prod # line 13 - -# Component scope (stacks/orgs/acme/plat/prod/us-east-2.yaml) -component: - vpc_name: main-vpc-us-east-2 # line 45 - cidr_block: 10.0.0.0/16 # line 46 - enable_nat_gateway: true # line 47 - -# Merged view (final resolved locals available to templates) -merged: - region: us-east-2 # from global - account_id: "123456789012" # from global - environment: prod # from global - state_bucket: terraform-state-123456789012 # from terraform - state_key_prefix: plat-ue2-prod # from terraform - vpc_name: main-vpc-us-east-2 # from component - cidr_block: 10.0.0.0/16 # from component - enable_nat_gateway: true # from component -``` - -**Example output (JSON with full provenance):** -```json -{ - "component": "vpc", - "stack": "plat-ue2-prod", - "component_type": "terraform", - "locals": { - "global": { - "source_file": "stacks/catalog/vpc.yaml", - "values": { - "region": {"value": "us-east-2", "line": 5}, - "account_id": {"value": "123456789012", "line": 6}, - "environment": {"value": "prod", "line": 7} - } - }, - "terraform": { - "source_file": "stacks/catalog/vpc.yaml", - "values": { - "state_bucket": {"value": "terraform-state-123456789012", "line": 12}, - "state_key_prefix": {"value": "plat-ue2-prod", "line": 13} - } - }, - "component": { - "source_file": "stacks/orgs/acme/plat/prod/us-east-2.yaml", - "values": { - "vpc_name": {"value": "main-vpc-us-east-2", "line": 45}, - "cidr_block": {"value": "10.0.0.0/16", "line": 46}, - "enable_nat_gateway": {"value": true, "line": 47} - } - } - }, - "merged": { - "region": {"value": "us-east-2", "scope": "global", "source_file": "stacks/catalog/vpc.yaml", "line": 5}, - "account_id": {"value": "123456789012", "scope": "global", "source_file": "stacks/catalog/vpc.yaml", "line": 6}, - "environment": {"value": "prod", "scope": "global", "source_file": "stacks/catalog/vpc.yaml", "line": 7}, - "state_bucket": {"value": "terraform-state-123456789012", "scope": "terraform", "source_file": "stacks/catalog/vpc.yaml", "line": 12}, - "state_key_prefix": {"value": "plat-ue2-prod", "scope": "terraform", "source_file": "stacks/catalog/vpc.yaml", "line": 13}, - "vpc_name": {"value": "main-vpc-us-east-2", "scope": "component", "source_file": "stacks/orgs/acme/plat/prod/us-east-2.yaml", "line": 45}, - "cidr_block": {"value": "10.0.0.0/16", "scope": "component", "source_file": "stacks/orgs/acme/plat/prod/us-east-2.yaml", "line": 46}, - "enable_nat_gateway": {"value": true, "scope": "component", "source_file": "stacks/orgs/acme/plat/prod/us-east-2.yaml", "line": 47} - } -} -``` - -**Key Design Decisions:** - -1. **Full provenance tracking**: Shows source file AND line number for each local -2. **Scope separation**: Clearly shows global, component-type, and component scopes -3. **Merged view with attribution**: The `merged` field shows where each final value originated -4. **JSON for tooling**: Full metadata in JSON format for programmatic access -5. **Consistent with other describe commands**: Same flags (`-s`, `--format`) as `atmos describe component` - -**Implementation Notes:** - -The command follows the existing `describe` command pattern: -- Located in `cmd/describe/describe_locals.go` -- Uses `CommandProvider` pattern from `cmd/internal/registry.go` -- Business logic in `internal/exec/describe_locals.go` -- Reuses `ProcessStackLocals()` and `ResolveComponentLocals()` from `internal/exec/stack_processor_locals.go` -- Leverages existing YAML position tracking (`pkg/utils/yaml_utils.go`) for line numbers - ### Component Registry Integration The Atmos component registry (`pkg/component/`) provides a provider-based architecture for component types (terraform, helmfile, packer, etc.). Locals integration with this system requires careful consideration. @@ -1838,3 +1821,110 @@ But this is **not needed for the initial implementation**. The `atmos describe l 3. **UX**: Error messages clearly explain the problem and suggest fixes 4. **Debugging**: Users can inspect resolved locals without guessing 5. **Adoption**: Users can reduce config duplication by 30%+ using locals + +--- + +## Implementation Status + +### Implemented Features (v1.203.0+) + +The following features have been implemented: + +#### Core Locals Resolution +- ✅ File-scoped locals (not inherited across imports) +- ✅ Global-level locals (stack file root) +- ✅ Section-level locals (terraform, helmfile, packer sections) +- ✅ Component-level locals (inside component definitions) +- ✅ Component-level locals inheritance (via `metadata.inherits` and `component` attribute) +- ✅ Locals referencing other locals +- ✅ Topological sorting for dependency resolution +- ✅ Circular dependency detection with clear error messages +- ✅ Integration with template processing (`{{ .locals.* }}`) + +#### `atmos describe locals` Command +- ✅ Show locals for a stack: `atmos describe locals --stack prod` (required) +- ✅ Show locals for a component in stack: `atmos describe locals vpc -s prod` +- ✅ JSON output format: `atmos describe locals -s prod --format json` +- ✅ Query support: `atmos describe locals -s prod --query '.locals.namespace'` +- ✅ File output: `atmos describe locals -s prod --file output.yaml` + +#### Implementation Files +- `internal/exec/stack_processor_locals.go` - LocalsContext and extraction +- `internal/exec/stack_processor_process_stacks_helpers.go` - ComponentLocals and BaseComponentLocals fields +- `internal/exec/stack_processor_process_stacks_helpers_extraction.go` - Component locals extraction +- `internal/exec/stack_processor_process_stacks_helpers_inheritance.go` - Component locals inheritance +- `internal/exec/stack_processor_utils.go` - Base component locals extraction and merging +- `internal/exec/stack_processor_merge.go` - Component locals merging in final output +- `internal/exec/describe_locals.go` - DescribeLocalsExec implementation with component locals +- `cmd/describe_locals.go` - CLI command definition +- `pkg/locals/resolver.go` - Dependency resolution with cycle detection +- `pkg/schema/schema.go` - BaseComponentLocals field in BaseComponentConfig +- `errors/errors.go` - Sentinel errors for locals (including ErrInvalidComponentLocals) + +### Not Yet Implemented + +The following features from the PRD are planned but not yet implemented: + +#### `ATMOS_DEBUG_LOCALS` Environment Variable +Verbose logging during stack processing has not been implemented. + +### Design Clarifications + +#### Stack Name Resolution in `describe locals` + +The `--stack` flag accepts two formats that both resolve to the same underlying stack manifest file: + +1. **Stack manifest file path** - Direct path relative to the stacks directory (e.g., `deploy/dev`, `prod`) +2. **Logical stack name** - The derived name based on your `atmos.yaml` stack name pattern (e.g., `prod-us-east-1`, `dev-ue2-sandbox`) + +Both formats resolve to the same file and return the same locals because **locals are file-scoped**. The command returns only the locals defined in that specific stack manifest file. + +**Example:** If your `atmos.yaml` has `name_pattern: "{stage}-{environment}"` and you have a file `stacks/deploy/prod-us-east-1.yaml`: +```bash +# These are equivalent - both resolve to the same file +atmos describe locals --stack deploy/prod-us-east-1 +atmos describe locals --stack prod-us-east-1 +``` + +#### Component Argument Semantics + +When you specify a component with the `--stack` flag, you're asking: *"What locals would be available to this component during template processing?"* + +The component argument does **not** mean the locals come from the component definition. Instead: +1. Atmos determines the component's type (terraform, helmfile, or packer) +2. Atmos merges the global locals with the corresponding section-specific locals +3. Atmos includes component-level locals (including those inherited from base components) +4. The result shows what `{{ .locals.* }}` references would resolve to for that component + +**Example:** +```yaml +# stacks/deploy/prod.yaml +locals: + namespace: acme # Global locals + +terraform: + locals: + backend_bucket: "{{ .locals.namespace }}-tfstate" # Terraform section locals +``` + +Running `atmos describe locals vpc -s deploy/prod` (where `vpc` is a Terraform component) returns: +```yaml +components: + terraform: + vpc: + locals: + namespace: acme + backend_bucket: acme-tfstate +``` + +The output uses Atmos schema format. The locals shown are merged from: (1) global locals, (2) section-specific locals from the stack manifest, and (3) component-level locals defined in the component itself (including those inherited from base components). The component argument determines the component type (terraform/helmfile/packer) for section-specific locals merging. + +#### File-Scoped Isolation + +A key design principle: when querying locals for a stack, you get **only** the locals defined in that stack manifest file, regardless of what files it imports. This is intentional: + +1. **Predictability** - You know exactly what locals are available by looking at the current file +2. **No hidden dependencies** - Locals won't mysteriously change based on import order +3. **Safer refactoring** - Renaming a local in one file won't break other files + +Use `vars` or `settings` for values that should propagate across imports. Use `locals` for file-internal convenience. diff --git a/errors/errors.go b/errors/errors.go index 47907cc6c2..9deee8f335 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -93,6 +93,8 @@ var ( ErrWorkflowNameRequired = errors.New("workflow name is required") ErrInvalidStackConfiguration = errors.New("invalid stack configuration") ErrPathNotWithinComponentBase = errors.New("path is not within component base path") + ErrStackRequired = errors.New("--stack flag is required") + ErrStackHasNoLocals = errors.New("stack has no locals defined") // ErrPlanHasDiff is returned when there are differences between two Terraform plan files. ErrPlanHasDiff = errors.New("plan files have differences") @@ -315,6 +317,7 @@ var ( ErrInvalidHooksSection = errors.New("invalid 'hooks' section in the file") ErrInvalidTerraformHooksSection = errors.New("invalid 'terraform.hooks' section in the file") ErrInvalidComponentVars = errors.New("invalid component vars section") + ErrInvalidComponentLocals = errors.New("invalid component locals section") ErrInvalidComponentSettings = errors.New("invalid component settings section") ErrInvalidComponentEnv = errors.New("invalid component env section") ErrInvalidComponentProviders = errors.New("invalid component providers section") diff --git a/internal/exec/describe_locals.go b/internal/exec/describe_locals.go new file mode 100644 index 0000000000..9143ec38d1 --- /dev/null +++ b/internal/exec/describe_locals.go @@ -0,0 +1,558 @@ +package exec + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + + "gopkg.in/yaml.v3" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/internal/tui/templates/term" + cfg "github.com/cloudposse/atmos/pkg/config" + log "github.com/cloudposse/atmos/pkg/logger" + m "github.com/cloudposse/atmos/pkg/merge" + "github.com/cloudposse/atmos/pkg/pager" + "github.com/cloudposse/atmos/pkg/perf" + "github.com/cloudposse/atmos/pkg/schema" + u "github.com/cloudposse/atmos/pkg/utils" +) + +// DescribeLocalsArgs holds the arguments for the describe locals command. +type DescribeLocalsArgs struct { + Component string + Query string + FilterByStack string + Format string + File string +} + +//go:generate go run go.uber.org/mock/mockgen@v0.6.0 -source=$GOFILE -destination=mock_describe_locals.go -package=$GOPACKAGE + +// DescribeLocalsExec defines the interface for executing describe locals. +type DescribeLocalsExec interface { + Execute(atmosConfig *schema.AtmosConfiguration, args *DescribeLocalsArgs) error +} + +type describeLocalsExec struct { + pageCreator pager.PageCreator + isTTYSupportForStdout func() bool + printOrWriteToFile func(atmosConfig *schema.AtmosConfiguration, format string, file string, data any) error + executeDescribeLocals func(atmosConfig *schema.AtmosConfiguration, filterByStack string) (map[string]any, error) + executeDescribeComponent func(params *ExecuteDescribeComponentParams) (map[string]any, error) +} + +// NewDescribeLocalsExec creates a new DescribeLocalsExec instance. +func NewDescribeLocalsExec() DescribeLocalsExec { + defer perf.Track(nil, "exec.NewDescribeLocalsExec")() + + return &describeLocalsExec{ + pageCreator: pager.New(), + isTTYSupportForStdout: term.IsTTYSupportForStdout, + printOrWriteToFile: printOrWriteToFile, + executeDescribeLocals: ExecuteDescribeLocals, + executeDescribeComponent: ExecuteDescribeComponent, + } +} + +// Execute executes the describe locals command. +func (d *describeLocalsExec) Execute(atmosConfig *schema.AtmosConfiguration, args *DescribeLocalsArgs) error { + defer perf.Track(atmosConfig, "exec.DescribeLocalsExec.Execute")() + + // Stack is required. + if args.FilterByStack == "" { + return errUtils.ErrStackRequired + } + + var res any + var err error + + // If component is specified, get locals for that specific component. + if args.Component != "" { + res, err = d.executeForComponent(atmosConfig, args) + if err != nil { + return err + } + } else { + // Get locals for the specified stack. + finalLocalsMap, err := d.executeDescribeLocals(atmosConfig, args.FilterByStack) + if err != nil { + return err + } + res = finalLocalsMap + } + + // Apply query if specified. + if args.Query != "" { + res, err = u.EvaluateYqExpression(atmosConfig, res, args.Query) + if err != nil { + return err + } + } + + return viewWithScroll(&viewWithScrollProps{ + pageCreator: d.pageCreator, + isTTYSupportForStdout: d.isTTYSupportForStdout, + printOrWriteToFile: d.printOrWriteToFile, + atmosConfig: atmosConfig, + displayName: "Locals", + format: args.Format, + file: args.File, + res: res, + }) +} + +// executeForComponent gets the locals for a specific component in a stack. +// Component-level locals are merged with stack-level locals (component locals take precedence). +func (d *describeLocalsExec) executeForComponent( + atmosConfig *schema.AtmosConfiguration, + args *DescribeLocalsArgs, +) (map[string]any, error) { + defer perf.Track(atmosConfig, "exec.DescribeLocalsExec.executeForComponent")() + + // Stack is validated in Execute(), but double-check for safety. + if args.FilterByStack == "" { + return nil, errUtils.ErrStackRequired + } + + componentSection, err := d.executeDescribeComponent(&ExecuteDescribeComponentParams{ + Component: args.Component, + Stack: args.FilterByStack, + ProcessTemplates: false, + ProcessYamlFunctions: false, + }) + if err != nil { + return nil, fmt.Errorf("failed to describe component %s in stack %s: %w", args.Component, args.FilterByStack, err) + } + + componentType := getComponentType(componentSection) + componentLocals := extractComponentLocals(componentSection) + + stackLocals, err := d.executeDescribeLocals(atmosConfig, args.FilterByStack) + if err != nil { + return nil, err + } + + return buildComponentLocalsResult(args, stackLocals, componentType, componentLocals) +} + +// getComponentType extracts the component type from a component section, defaulting to terraform. +func getComponentType(componentSection map[string]any) string { + if ct, ok := componentSection["component_type"].(string); ok && ct != "" { + return ct + } + return "terraform" +} + +// extractComponentLocals extracts the locals section from a component section. +func extractComponentLocals(componentSection map[string]any) map[string]any { + if cl, ok := componentSection[cfg.LocalsSectionName].(map[string]any); ok { + return cl + } + return nil +} + +// buildComponentLocalsResult builds the result map for component locals query. +// Output format matches Atmos stack manifest schema: +// +// components: +// terraform: +// vpc: +// locals: +// foo: 123 +func buildComponentLocalsResult( + args *DescribeLocalsArgs, + stackLocals map[string]any, + componentType string, + componentLocals map[string]any, +) (map[string]any, error) { + // stackLocals is now in direct format (locals:, terraform:, etc.) without stack name wrapper. + // Merge stack-level locals with component-level locals. + if len(stackLocals) > 0 { + stackTypeLocals := getLocalsForComponentType(stackLocals, componentType) + mergedLocals := mergeLocals(stackTypeLocals, componentLocals) + return buildComponentSchemaOutput(args.Component, componentType, mergedLocals), nil + } + + if len(componentLocals) > 0 { + return buildComponentSchemaOutput(args.Component, componentType, componentLocals), nil + } + + return nil, fmt.Errorf("%w: %s", errUtils.ErrStackHasNoLocals, args.FilterByStack) +} + +// buildComponentSchemaOutput creates the Atmos schema-compliant output for component locals. +func buildComponentSchemaOutput(component, componentType string, locals map[string]any) map[string]any { + return map[string]any{ + "components": map[string]any{ + componentType: map[string]any{ + component: map[string]any{ + cfg.LocalsSectionName: locals, + }, + }, + }, + } +} + +// mergeLocals deep-merges base and override locals maps. +// This uses the same deep-merge semantics as vars, settings, and other Atmos sections, +// so nested maps (e.g., tags: {env: dev, team: platform}) are recursively merged +// rather than entirely replaced. +func mergeLocals(base, override map[string]any) map[string]any { + if base == nil { + base = make(map[string]any) + } + if override == nil { + return base + } + + // Use pkg/merge for consistent deep-merge behavior with the rest of Atmos. + // MergeWithOptions handles deep copying internally to avoid pointer mutation. + result, err := m.MergeWithOptions(nil, []map[string]any{base, override}, false, false) + if err != nil { + // On merge error, fall back to shallow merge for robustness. + log.Warn("Deep-merge failed, falling back to shallow merge", "error", err) + result = make(map[string]any, len(base)+len(override)) + for k, v := range base { + result[k] = v + } + for k, v := range override { + result[k] = v + } + } + + return result +} + +// getLocalsForComponentType extracts the appropriate merged locals for a component type. +// Input format is Atmos schema: locals: {...}, terraform: {locals: {...}}, etc. +// Uses mergeLocals for consistent deep-merge semantics with nested maps. +func getLocalsForComponentType(stackLocals map[string]any, componentType string) map[string]any { + result := make(map[string]any) + + // Start with global locals (root-level "locals:" key). + if globalLocals, ok := stackLocals[cfg.LocalsSectionName].(map[string]any); ok { + result = mergeLocals(result, globalLocals) + } + + // Merge section-specific locals (e.g., "terraform: locals:"). + if sectionMap, ok := stackLocals[componentType].(map[string]any); ok { + if sectionLocals, ok := sectionMap[cfg.LocalsSectionName].(map[string]any); ok { + result = mergeLocals(result, sectionLocals) + } + } + + return result +} + +// stackFileLocalsResult holds the result of processing a stack file for locals. +type stackFileLocalsResult struct { + StackName string // Derived stack name (empty if filtered out or unparseable). + StackLocals map[string]any // Locals extracted from the stack file. + Found bool // Whether the stack matched the filter (even if no locals). +} + +// ExecuteDescribeLocals processes stack manifests and returns the locals for the specified stack. +// It reads the raw YAML files directly since locals are stripped during normal stack processing. +// The output format matches the stack manifest schema (locals at root level). +func ExecuteDescribeLocals( + atmosConfig *schema.AtmosConfiguration, + filterByStack string, +) (map[string]any, error) { + defer perf.Track(atmosConfig, "exec.ExecuteDescribeLocals")() + + // Normalize path separators for Windows compatibility. + // deriveStackFileName returns forward-slash paths, so we need to match that format. + filterByStack = filepath.ToSlash(filterByStack) + + stackFound := false + var stackLocals map[string]any + + // Process each stack config file directly. + for _, filePath := range atmosConfig.StackConfigFilesAbsolutePaths { + result, err := processStackFileForLocals(atmosConfig, filePath, filterByStack) + if err != nil { + return nil, err + } + + // Track if we found a matching stack (even with no locals). + if result.Found { + stackFound = true + stackLocals = result.StackLocals + break // Found the matching stack, no need to continue. + } + } + + // Validate the result. + if !stackFound { + return nil, fmt.Errorf("%w: %s", errUtils.ErrStackNotFound, filterByStack) + } + if len(stackLocals) == 0 { + return nil, fmt.Errorf("%w: %s", errUtils.ErrStackHasNoLocals, filterByStack) + } + + return stackLocals, nil +} + +// parseStackFileYAML reads and parses a stack file's YAML content. +// Returns the raw config, or nil if the file should be skipped. +// If filterMatchesFileName is true, parse errors return an error; otherwise they are logged and skipped. +func parseStackFileYAML(filePath string, filterMatchesFileName bool) (map[string]any, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, errors.Join(errUtils.ErrInvalidStackManifest, fmt.Errorf("failed to read stack file %s: %w", filePath, err)) + } + + var rawConfig map[string]any + if err := yaml.Unmarshal(content, &rawConfig); err != nil { + if filterMatchesFileName { + return nil, errors.Join(errUtils.ErrInvalidStackManifest, fmt.Errorf("failed to parse YAML in %s: %w", filePath, err)) + } + log.Warn("Skipping file with YAML parse error", "file", filePath, "error", err) + return nil, nil //nolint:nilnil // nil config signals skip without error + } + + return rawConfig, nil +} + +// stackMatchesFilter checks if a stack matches the filter criteria. +// Returns true if no filter is specified or if the filter matches either the filename or derived name. +func stackMatchesFilter(filterByStack, stackFileName, stackName string) bool { + if filterByStack == "" { + return true + } + return filterByStack == stackFileName || filterByStack == stackName +} + +// processStackFileForLocals reads a stack file and extracts its locals. +// Returns a result struct with stack name, locals, and whether the stack matched the filter. +func processStackFileForLocals( + atmosConfig *schema.AtmosConfiguration, + filePath string, + filterByStack string, +) (*stackFileLocalsResult, error) { + stackFileName := deriveStackFileName(atmosConfig, filePath) + filterMatchesFileName := filterByStack != "" && filterByStack == stackFileName + + rawConfig, err := parseStackFileYAML(filePath, filterMatchesFileName) + if err != nil { + return nil, err + } + if rawConfig == nil { + return &stackFileLocalsResult{}, nil + } + + // Extract vars for stack name derivation. + var varsSection map[string]any + if vs, ok := rawConfig[cfg.VarsSectionName].(map[string]any); ok { + varsSection = vs + } + + stackName := deriveStackName(atmosConfig, stackFileName, varsSection, rawConfig) + + // Apply filter if specified. + if !stackMatchesFilter(filterByStack, stackFileName, stackName) { + return &stackFileLocalsResult{}, nil + } + + localsCtx, err := ProcessStackLocals(atmosConfig, rawConfig, filePath) + if err != nil { + return &stackFileLocalsResult{Found: true}, fmt.Errorf("failed to process locals for stack %s: %w", stackFileName, err) + } + + return &stackFileLocalsResult{ + StackName: stackName, + StackLocals: buildStackLocalsFromContext(localsCtx), + Found: true, + }, nil +} + +// buildStackLocalsFromContext converts a LocalsContext to a map using Atmos schema format. +// Returns an empty map if localsCtx is nil or has no locals. +// Output format matches Atmos stack manifest schema: +// +// locals: +// foo: 123 +// terraform: +// locals: +// xyz: 123 +func buildStackLocalsFromContext(localsCtx *LocalsContext) map[string]any { + stackLocals := make(map[string]any) + + if localsCtx == nil { + return stackLocals + } + + // Global locals go under root "locals:" key. + if len(localsCtx.Global) > 0 { + stackLocals[cfg.LocalsSectionName] = localsCtx.Global + } + + // Section-specific locals go under "terraform: locals:", "helmfile: locals:", etc. + if localsCtx.HasTerraformLocals && len(localsCtx.Terraform) > 0 { + stackLocals[cfg.TerraformSectionName] = map[string]any{ + cfg.LocalsSectionName: getSectionOnlyLocals(localsCtx.Terraform, localsCtx.Global), + } + } + + if localsCtx.HasHelmfileLocals && len(localsCtx.Helmfile) > 0 { + stackLocals[cfg.HelmfileSectionName] = map[string]any{ + cfg.LocalsSectionName: getSectionOnlyLocals(localsCtx.Helmfile, localsCtx.Global), + } + } + + if localsCtx.HasPackerLocals && len(localsCtx.Packer) > 0 { + stackLocals[cfg.PackerSectionName] = map[string]any{ + cfg.LocalsSectionName: getSectionOnlyLocals(localsCtx.Packer, localsCtx.Global), + } + } + + return stackLocals +} + +// getSectionOnlyLocals extracts locals that are unique to a section (not inherited from global). +// Since section locals are already merged with global, we need to extract only the section-specific ones. +func getSectionOnlyLocals(sectionLocals, globalLocals map[string]any) map[string]any { + result := make(map[string]any) + for k, v := range sectionLocals { + // Include if key doesn't exist in global, or if value differs from global. + if globalVal, exists := globalLocals[k]; !exists || !valuesEqual(v, globalVal) { + result[k] = v + } + } + return result +} + +// valuesEqual compares two values for deep equality. +func valuesEqual(a, b any) bool { + return reflect.DeepEqual(a, b) +} + +// deriveStackFileName extracts the stack file name from the absolute path. +// It removes the stacks base path and file extension to get the relative stack name. +// The returned path always uses forward slashes for consistency across platforms. +func deriveStackFileName(atmosConfig *schema.AtmosConfiguration, filePath string) string { + defer perf.Track(atmosConfig, "exec.deriveStackFileName")() + + // Get the relative path from the stacks base path. + stacksBasePath := atmosConfig.StacksBaseAbsolutePath + if stacksBasePath == "" { + // Fallback: just use the file name without extension. + base := filepath.Base(filePath) + return strings.TrimSuffix(base, filepath.Ext(base)) + } + + // Get relative path. + relPath, err := filepath.Rel(stacksBasePath, filePath) + if err != nil { + // Fallback: just use the file name without extension. + base := filepath.Base(filePath) + return strings.TrimSuffix(base, filepath.Ext(base)) + } + + // Remove the extension and normalize path separators to forward slashes. + result := strings.TrimSuffix(relPath, filepath.Ext(relPath)) + return filepath.ToSlash(result) +} + +// deriveStackName derives the stack name using the same logic as describe stacks. +func deriveStackName( + atmosConfig *schema.AtmosConfiguration, + stackFileName string, + varsSection map[string]any, + stackSectionMap map[string]any, +) string { + defer perf.Track(atmosConfig, "exec.deriveStackName")() + + // Try explicit name from manifest first. + if name := getExplicitStackName(stackSectionMap); name != "" { + return name + } + + // Try name template. + if name := deriveStackNameFromTemplate(atmosConfig, stackFileName, varsSection); name != "" { + return name + } + + // Try name pattern. + if name := deriveStackNameFromPattern(atmosConfig, stackFileName, varsSection); name != "" { + return name + } + + // Default: use stack filename. + return stackFileName +} + +// getExplicitStackName extracts an explicit name from the stack manifest if defined. +func getExplicitStackName(stackSectionMap map[string]any) string { + nameValue, ok := stackSectionMap[cfg.NameSectionName] + if !ok { + return "" + } + name, ok := nameValue.(string) + if !ok || name == "" { + return "" + } + return name +} + +// deriveStackNameFromTemplate derives a stack name using the configured name template. +// Returns empty string if template is not configured or evaluation fails. +func deriveStackNameFromTemplate( + atmosConfig *schema.AtmosConfiguration, + stackFileName string, + varsSection map[string]any, +) string { + if atmosConfig.Stacks.NameTemplate == "" { + return "" + } + + // Wrap varsSection in "vars" key to match template syntax: {{ .vars.environment }}. + templateData := map[string]any{ + "vars": varsSection, + } + + stackName, err := ProcessTmpl(atmosConfig, "describe-locals-name-template", atmosConfig.Stacks.NameTemplate, templateData, false) + if err != nil { + log.Debug("Failed to evaluate name template for stack", "file", stackFileName, "error", err) + return "" + } + + if stackName == "" { + return "" + } + + // If vars contain unresolved templates (e.g., "{{ .locals.* }}"), the result + // will contain raw template markers. Fall back to empty (use filename). + if strings.Contains(stackName, "{{") || strings.Contains(stackName, "}}") { + log.Debug("Name template result contains unresolved templates, using filename", "file", stackFileName, "result", stackName) + return "" + } + + return stackName +} + +// deriveStackNameFromPattern derives a stack name using the configured name pattern. +// Returns empty string if pattern is not configured or evaluation fails. +func deriveStackNameFromPattern( + atmosConfig *schema.AtmosConfiguration, + stackFileName string, + varsSection map[string]any, +) string { + pattern := GetStackNamePattern(atmosConfig) + if pattern == "" { + return "" + } + + context := cfg.GetContextFromVars(varsSection) + stackName, err := cfg.GetContextPrefix(stackFileName, context, pattern, stackFileName) + if err != nil { + log.Debug("Failed to evaluate name pattern for stack", "file", stackFileName, "error", err) + return "" + } + + return stackName +} diff --git a/internal/exec/describe_locals_test.go b/internal/exec/describe_locals_test.go new file mode 100644 index 0000000000..96d21b4648 --- /dev/null +++ b/internal/exec/describe_locals_test.go @@ -0,0 +1,1669 @@ +package exec + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/schema" +) + +func TestDeriveStackFileName(t *testing.T) { + // Use t.TempDir() for OS-neutral paths. + tempDir := t.TempDir() + stacksBase := filepath.Join(tempDir, "stacks") + otherDir := filepath.Join(tempDir, "other", "location") + + t.Run("simple file path", func(t *testing.T) { + atmosConfig := &mockAtmosConfig{stacksBaseAbsolutePath: stacksBase} + filePath := filepath.Join(stacksBase, "dev.yaml") + result := deriveStackFileName(atmosConfig.toSchema(), filePath) + assert.Equal(t, "dev", result) + }) + + t.Run("nested file path", func(t *testing.T) { + atmosConfig := &mockAtmosConfig{stacksBaseAbsolutePath: stacksBase} + filePath := filepath.Join(stacksBase, "deploy", "dev.yaml") + result := deriveStackFileName(atmosConfig.toSchema(), filePath) + assert.Equal(t, "deploy/dev", result) + }) + + t.Run("deeply nested file path", func(t *testing.T) { + atmosConfig := &mockAtmosConfig{stacksBaseAbsolutePath: stacksBase} + filePath := filepath.Join(stacksBase, "org", "team", "deploy", "dev.yaml") + result := deriveStackFileName(atmosConfig.toSchema(), filePath) + assert.Equal(t, "org/team/deploy/dev", result) + }) + + t.Run("empty base path falls back to filename", func(t *testing.T) { + atmosConfig := &mockAtmosConfig{stacksBaseAbsolutePath: ""} + filePath := filepath.Join(stacksBase, "deploy", "dev.yaml") + result := deriveStackFileName(atmosConfig.toSchema(), filePath) + assert.Equal(t, "dev", result) + }) + + t.Run("yml extension", func(t *testing.T) { + atmosConfig := &mockAtmosConfig{stacksBaseAbsolutePath: stacksBase} + filePath := filepath.Join(stacksBase, "prod.yml") + result := deriveStackFileName(atmosConfig.toSchema(), filePath) + assert.Equal(t, "prod", result) + }) + + t.Run("file path not under base path returns relative path", func(t *testing.T) { + atmosConfig := &mockAtmosConfig{stacksBaseAbsolutePath: stacksBase} + filePath := filepath.Join(otherDir, "dev.yaml") + result := deriveStackFileName(atmosConfig.toSchema(), filePath) + // Result is normalized with forward slashes and contains ".." to traverse up. + assert.Contains(t, result, "..") + assert.Contains(t, result, "dev") + }) +} + +func TestDeriveStackName(t *testing.T) { + tests := []struct { + name string + stackFileName string + varsSection map[string]any + stackSectionMap map[string]any + expected string + }{ + { + name: "explicit name in manifest", + stackFileName: "deploy/dev", + varsSection: nil, + stackSectionMap: map[string]any{ + "name": "my-custom-stack-name", + }, + expected: "my-custom-stack-name", + }, + { + name: "empty name falls back to filename", + stackFileName: "deploy/dev", + varsSection: nil, + stackSectionMap: map[string]any{ + "name": "", + }, + expected: "deploy/dev", + }, + { + name: "no name uses filename", + stackFileName: "deploy/prod", + varsSection: nil, + stackSectionMap: map[string]any{}, + expected: "deploy/prod", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + atmosConfig := &mockAtmosConfig{} + + result := deriveStackName(atmosConfig.toSchema(), tt.stackFileName, tt.varsSection, tt.stackSectionMap) + assert.Equal(t, tt.expected, result) + }) + } +} + +// mockAtmosConfig is a helper for creating test configurations. +type mockAtmosConfig struct { + stacksBaseAbsolutePath string + nameTemplate string + namePattern string +} + +func (m *mockAtmosConfig) toSchema() *schema.AtmosConfiguration { + return &schema.AtmosConfiguration{ + StacksBaseAbsolutePath: m.stacksBaseAbsolutePath, + Stacks: schema.Stacks{ + NameTemplate: m.nameTemplate, + NamePattern: m.namePattern, + }, + } +} + +func TestBuildStackLocalsFromContext(t *testing.T) { + tests := []struct { + name string + localsCtx *LocalsContext + expectKeys []string + }{ + { + name: "nil context returns empty map", + localsCtx: nil, + expectKeys: []string{}, + }, + { + name: "global only", + localsCtx: &LocalsContext{ + Global: map[string]any{"key": "value"}, + }, + expectKeys: []string{"locals"}, + }, + { + name: "terraform locals", + localsCtx: &LocalsContext{ + Global: map[string]any{"global_key": "global_value"}, + Terraform: map[string]any{"global_key": "global_value", "tf_key": "tf_value"}, + HasTerraformLocals: true, + }, + expectKeys: []string{"locals", "terraform"}, + }, + { + name: "helmfile locals", + localsCtx: &LocalsContext{ + Global: map[string]any{"global_key": "global_value"}, + Helmfile: map[string]any{"global_key": "global_value", "hf_key": "hf_value"}, + HasHelmfileLocals: true, + }, + expectKeys: []string{"locals", "helmfile"}, + }, + { + name: "packer locals", + localsCtx: &LocalsContext{ + Global: map[string]any{"global_key": "global_value"}, + Packer: map[string]any{"global_key": "global_value", "pk_key": "pk_value"}, + HasPackerLocals: true, + }, + expectKeys: []string{"locals", "packer"}, + }, + { + name: "all sections", + localsCtx: &LocalsContext{ + Global: map[string]any{"global_key": "global_value"}, + Terraform: map[string]any{"global_key": "global_value", "tf_key": "tf_value"}, + Helmfile: map[string]any{"global_key": "global_value", "hf_key": "hf_value"}, + Packer: map[string]any{"global_key": "global_value", "pk_key": "pk_value"}, + HasTerraformLocals: true, + HasHelmfileLocals: true, + HasPackerLocals: true, + }, + expectKeys: []string{"locals", "terraform", "helmfile", "packer"}, + }, + { + name: "empty global returns empty map", + localsCtx: &LocalsContext{ + Global: map[string]any{}, + }, + expectKeys: []string{}, + }, + { + name: "terraform without flag not included", + localsCtx: &LocalsContext{ + Global: map[string]any{"key": "value"}, + Terraform: map[string]any{"tf_key": "tf_value"}, + HasTerraformLocals: false, + }, + expectKeys: []string{"locals"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildStackLocalsFromContext(tt.localsCtx) + assert.Len(t, result, len(tt.expectKeys)) + for _, key := range tt.expectKeys { + assert.Contains(t, result, key) + } + }) + } +} + +func TestProcessStackFileForLocals(t *testing.T) { + // Create a temporary directory for test files. + tempDir := t.TempDir() + + // Create a valid YAML file with locals. + validYAML := ` +locals: + namespace: acme + environment: dev +vars: + stage: test +` + validFile := filepath.Join(tempDir, "valid.yaml") + err := os.WriteFile(validFile, []byte(validYAML), 0o644) + require.NoError(t, err) + + // Create an invalid YAML file. + invalidYAML := `invalid: yaml: content: [broken` + invalidFile := filepath.Join(tempDir, "invalid.yaml") + err = os.WriteFile(invalidFile, []byte(invalidYAML), 0o644) + require.NoError(t, err) + + // Create an empty YAML file. + emptyFile := filepath.Join(tempDir, "empty.yaml") + err = os.WriteFile(emptyFile, []byte(""), 0o644) + require.NoError(t, err) + + atmosConfig := &schema.AtmosConfiguration{ + StacksBaseAbsolutePath: tempDir, + } + + t.Run("valid file with locals", func(t *testing.T) { + result, err := processStackFileForLocals(atmosConfig, validFile, "") + require.NoError(t, err) + assert.Equal(t, "valid", result.StackName) + assert.NotEmpty(t, result.StackLocals) + assert.Contains(t, result.StackLocals, "locals") + assert.True(t, result.Found) + }) + + t.Run("file not found", func(t *testing.T) { + missingFile := filepath.Join(tempDir, "does-not-exist.yaml") + _, err := processStackFileForLocals(atmosConfig, missingFile, "") + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrInvalidStackManifest) + assert.Contains(t, err.Error(), "failed to read stack file") + }) + + t.Run("invalid YAML returns empty result", func(t *testing.T) { + result, err := processStackFileForLocals(atmosConfig, invalidFile, "") + require.NoError(t, err) + assert.Empty(t, result.StackName) + assert.Empty(t, result.StackLocals) + assert.False(t, result.Found) + }) + + t.Run("empty file returns empty result", func(t *testing.T) { + result, err := processStackFileForLocals(atmosConfig, emptyFile, "") + require.NoError(t, err) + assert.Empty(t, result.StackName) + assert.Empty(t, result.StackLocals) + assert.False(t, result.Found) + }) + + t.Run("filter by stack name matches", func(t *testing.T) { + result, err := processStackFileForLocals(atmosConfig, validFile, "valid") + require.NoError(t, err) + assert.Equal(t, "valid", result.StackName) + assert.NotEmpty(t, result.StackLocals) + assert.True(t, result.Found) + }) + + t.Run("filter by stack name does not match", func(t *testing.T) { + result, err := processStackFileForLocals(atmosConfig, validFile, "other-stack") + require.NoError(t, err) + assert.Empty(t, result.StackName) + assert.Empty(t, result.StackLocals) + assert.False(t, result.Found) + }) + + t.Run("filter matching invalid YAML returns error", func(t *testing.T) { + // When filtering by a stack that has YAML errors, return error instead of silently skipping. + _, err := processStackFileForLocals(atmosConfig, invalidFile, "invalid") + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrInvalidStackManifest) + assert.Contains(t, err.Error(), "failed to parse YAML") + }) +} + +func TestExecuteDescribeLocals(t *testing.T) { + // Create a temporary directory for test files. + tempDir := t.TempDir() + + // Create stack files. + devYAML := ` +locals: + namespace: acme + environment: dev +` + devFile := filepath.Join(tempDir, "dev.yaml") + err := os.WriteFile(devFile, []byte(devYAML), 0o644) + require.NoError(t, err) + + prodYAML := ` +locals: + namespace: acme + environment: prod +` + prodFile := filepath.Join(tempDir, "prod.yaml") + err = os.WriteFile(prodFile, []byte(prodYAML), 0o644) + require.NoError(t, err) + + atmosConfig := &schema.AtmosConfiguration{ + StacksBaseAbsolutePath: tempDir, + StackConfigFilesAbsolutePaths: []string{devFile, prodFile}, + } + + t.Run("returns locals for dev stack in direct format", func(t *testing.T) { + result, err := ExecuteDescribeLocals(atmosConfig, "dev") + require.NoError(t, err) + // Result should be direct format: locals: {...} + assert.Contains(t, result, "locals") + locals, ok := result["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme", locals["namespace"]) + assert.Equal(t, "dev", locals["environment"]) + }) + + t.Run("returns locals for prod stack in direct format", func(t *testing.T) { + result, err := ExecuteDescribeLocals(atmosConfig, "prod") + require.NoError(t, err) + // Result should be direct format: locals: {...} + assert.Contains(t, result, "locals") + locals, ok := result["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme", locals["namespace"]) + assert.Equal(t, "prod", locals["environment"]) + }) + + t.Run("returns error for nonexistent stack", func(t *testing.T) { + _, err := ExecuteDescribeLocals(atmosConfig, "nonexistent") + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrStackNotFound) + }) +} + +func TestDeriveStackNameWithTemplate(t *testing.T) { + tests := []struct { + name string + stackFileName string + varsSection map[string]any + stackSectionMap map[string]any + nameTemplate string + expected string + }{ + { + name: "name template with vars", + stackFileName: "deploy/dev", + varsSection: map[string]any{ + "namespace": "acme", + "environment": "dev", + "stage": "us-east-1", + }, + stackSectionMap: map[string]any{}, + // Template uses .vars.* to access varsSection values. + nameTemplate: "{{ .vars.namespace }}-{{ .vars.environment }}-{{ .vars.stage }}", + expected: "acme-dev-us-east-1", + }, + { + name: "explicit name overrides template", + stackFileName: "deploy/dev", + varsSection: map[string]any{ + "namespace": "acme", + }, + stackSectionMap: map[string]any{ + "name": "custom-name", + }, + nameTemplate: "{{ .vars.namespace }}-derived", + expected: "custom-name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + Stacks: schema.Stacks{ + NameTemplate: tt.nameTemplate, + }, + } + result := deriveStackName(atmosConfig, tt.stackFileName, tt.varsSection, tt.stackSectionMap) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestDeriveStackNameWithNamePattern(t *testing.T) { + tests := []struct { + name string + stackFileName string + varsSection map[string]any + stackSectionMap map[string]any + namePattern string + expected string + }{ + { + name: "name pattern with vars", + stackFileName: "deploy/dev", + varsSection: map[string]any{ + "namespace": "acme", + "environment": "dev", + "stage": "us-east-1", + }, + stackSectionMap: map[string]any{}, + namePattern: "{namespace}-{environment}-{stage}", + expected: "acme-dev-us-east-1", + }, + { + name: "explicit name overrides pattern", + stackFileName: "deploy/dev", + varsSection: map[string]any{ + "namespace": "acme", + }, + stackSectionMap: map[string]any{ + "name": "custom-name", + }, + namePattern: "{namespace}-derived", + expected: "custom-name", + }, + { + name: "fallback to filename when vars missing", + stackFileName: "deploy/prod", + varsSection: map[string]any{}, + stackSectionMap: map[string]any{}, + namePattern: "{namespace}-{environment}", + expected: "deploy/prod", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{ + Stacks: schema.Stacks{ + NamePattern: tt.namePattern, + }, + } + result := deriveStackName(atmosConfig, tt.stackFileName, tt.varsSection, tt.stackSectionMap) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestNewDescribeLocalsExec(t *testing.T) { + exec := NewDescribeLocalsExec() + assert.NotNil(t, exec) +} + +func TestGetLocalsForComponentType(t *testing.T) { + tests := []struct { + name string + stackLocals map[string]any + componentType string + expected map[string]any + }{ + { + name: "returns terraform section when available", + stackLocals: map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + }, + "terraform": map[string]any{ + "locals": map[string]any{ + "backend_bucket": "acme-tfstate", + }, + }, + }, + componentType: "terraform", + expected: map[string]any{ + "namespace": "acme", + "backend_bucket": "acme-tfstate", + }, + }, + { + name: "returns helmfile section when available", + stackLocals: map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + }, + "helmfile": map[string]any{ + "locals": map[string]any{ + "release_dir": "/releases", + }, + }, + }, + componentType: "helmfile", + expected: map[string]any{ + "namespace": "acme", + "release_dir": "/releases", + }, + }, + { + name: "returns packer section when available", + stackLocals: map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + }, + "packer": map[string]any{ + "locals": map[string]any{ + "ami_name": "my-ami", + }, + }, + }, + componentType: "packer", + expected: map[string]any{ + "namespace": "acme", + "ami_name": "my-ami", + }, + }, + { + name: "falls back to locals only when section not available", + stackLocals: map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + "environment": "dev", + }, + }, + componentType: "terraform", + expected: map[string]any{ + "namespace": "acme", + "environment": "dev", + }, + }, + { + name: "returns only global locals when section not available", + stackLocals: map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + }, + }, + componentType: "terraform", + expected: map[string]any{ + "namespace": "acme", + }, + }, + { + name: "returns empty map when no locals available", + stackLocals: map[string]any{}, + componentType: "terraform", + expected: map[string]any{}, + }, + { + name: "handles unknown component type", + stackLocals: map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + }, + }, + componentType: "unknown", + expected: map[string]any{ + "namespace": "acme", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getLocalsForComponentType(tt.stackLocals, tt.componentType) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExecuteForComponent(t *testing.T) { + t.Run("requires stack", func(t *testing.T) { + exec := &describeLocalsExec{} + + args := &DescribeLocalsArgs{ + Component: "vpc", + // FilterByStack is empty - should error. + } + + _, err := exec.executeForComponent(&schema.AtmosConfiguration{}, args) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrStackRequired) + }) + + t.Run("returns error when component not found", func(t *testing.T) { + expectedErr := errors.New("component not found") + + exec := &describeLocalsExec{ + executeDescribeComponent: func(params *ExecuteDescribeComponentParams) (map[string]any, error) { + return nil, expectedErr + }, + } + + args := &DescribeLocalsArgs{ + Component: "nonexistent", + FilterByStack: "dev", + } + + _, err := exec.executeForComponent(&schema.AtmosConfiguration{}, args) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to describe component") + }) + + t.Run("returns error when stack has no locals", func(t *testing.T) { + exec := &describeLocalsExec{ + executeDescribeComponent: func(params *ExecuteDescribeComponentParams) (map[string]any, error) { + return map[string]any{ + "component_type": "terraform", + }, nil + }, + executeDescribeLocals: func(ac *schema.AtmosConfiguration, filterByStack string) (map[string]any, error) { + // Return empty map - stack exists but has no locals. + return map[string]any{}, nil + }, + } + + args := &DescribeLocalsArgs{ + Component: "vpc", + FilterByStack: "dev", + } + + _, err := exec.executeForComponent(&schema.AtmosConfiguration{}, args) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrStackHasNoLocals) + }) + + t.Run("returns error when stack not found", func(t *testing.T) { + exec := &describeLocalsExec{ + executeDescribeComponent: func(params *ExecuteDescribeComponentParams) (map[string]any, error) { + return map[string]any{ + "component_type": "terraform", + }, nil + }, + executeDescribeLocals: func(ac *schema.AtmosConfiguration, filterByStack string) (map[string]any, error) { + // Return ErrStackNotFound - stack doesn't exist. + return nil, fmt.Errorf("%w: %s", errUtils.ErrStackNotFound, filterByStack) + }, + } + + args := &DescribeLocalsArgs{ + Component: "vpc", + FilterByStack: "nonexistent", + } + + _, err := exec.executeForComponent(&schema.AtmosConfiguration{}, args) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrStackNotFound) + }) + + t.Run("returns locals for terraform component", func(t *testing.T) { + exec := &describeLocalsExec{ + executeDescribeComponent: func(params *ExecuteDescribeComponentParams) (map[string]any, error) { + assert.Equal(t, "vpc", params.Component) + assert.Equal(t, "dev", params.Stack) + return map[string]any{ + "component_type": "terraform", + }, nil + }, + executeDescribeLocals: func(ac *schema.AtmosConfiguration, filterByStack string) (map[string]any, error) { + // New direct format (no stack name wrapper). + return map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + "environment": "dev", + }, + "terraform": map[string]any{ + "locals": map[string]any{ + "backend_bucket": "acme-dev-tfstate", + }, + }, + }, nil + }, + } + + args := &DescribeLocalsArgs{ + Component: "vpc", + FilterByStack: "dev", + } + + result, err := exec.executeForComponent(&schema.AtmosConfiguration{}, args) + require.NoError(t, err) + + // Verify Atmos schema format. + components, ok := result["components"].(map[string]any) + require.True(t, ok) + terraform, ok := components["terraform"].(map[string]any) + require.True(t, ok) + vpc, ok := terraform["vpc"].(map[string]any) + require.True(t, ok) + locals, ok := vpc["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme", locals["namespace"]) + assert.Equal(t, "dev", locals["environment"]) + assert.Equal(t, "acme-dev-tfstate", locals["backend_bucket"]) + }) + + t.Run("returns locals for helmfile component", func(t *testing.T) { + exec := &describeLocalsExec{ + executeDescribeComponent: func(params *ExecuteDescribeComponentParams) (map[string]any, error) { + return map[string]any{ + "component_type": "helmfile", + }, nil + }, + executeDescribeLocals: func(ac *schema.AtmosConfiguration, filterByStack string) (map[string]any, error) { + // New direct format (no stack name wrapper). + return map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + }, + "helmfile": map[string]any{ + "locals": map[string]any{ + "release_name": "my-release", + }, + }, + }, nil + }, + } + + args := &DescribeLocalsArgs{ + Component: "nginx", + FilterByStack: "prod", + } + + result, err := exec.executeForComponent(&schema.AtmosConfiguration{}, args) + require.NoError(t, err) + + // Verify Atmos schema format. + components, ok := result["components"].(map[string]any) + require.True(t, ok) + helmfile, ok := components["helmfile"].(map[string]any) + require.True(t, ok) + nginx, ok := helmfile["nginx"].(map[string]any) + require.True(t, ok) + locals, ok := nginx["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "my-release", locals["release_name"]) + }) + + t.Run("defaults to terraform when component_type not set", func(t *testing.T) { + exec := &describeLocalsExec{ + executeDescribeComponent: func(params *ExecuteDescribeComponentParams) (map[string]any, error) { + // Return without component_type. + return map[string]any{}, nil + }, + executeDescribeLocals: func(ac *schema.AtmosConfiguration, filterByStack string) (map[string]any, error) { + // New direct format (no stack name wrapper). + return map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + }, + }, nil + }, + } + + args := &DescribeLocalsArgs{ + Component: "vpc", + FilterByStack: "dev", + } + + result, err := exec.executeForComponent(&schema.AtmosConfiguration{}, args) + require.NoError(t, err) + + // Should default to terraform - check in Atmos schema format. + components, ok := result["components"].(map[string]any) + require.True(t, ok) + _, hasTerraform := components["terraform"] + assert.True(t, hasTerraform, "should default to terraform component type") + }) +} + +// TestExecuteForComponentOutputStructure verifies that component queries return the correct structure. +func TestExecuteForComponentOutputStructure(t *testing.T) { + t.Run("component output has expected structure", func(t *testing.T) { + exec := &describeLocalsExec{ + executeDescribeComponent: func(params *ExecuteDescribeComponentParams) (map[string]any, error) { + return map[string]any{ + "component_type": "terraform", + }, nil + }, + executeDescribeLocals: func(ac *schema.AtmosConfiguration, filterByStack string) (map[string]any, error) { + // New direct format (no stack name wrapper). + return map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + }, + "terraform": map[string]any{ + "locals": map[string]any{ + "backend_bucket": "acme-tfstate", + }, + }, + }, nil + }, + } + + args := &DescribeLocalsArgs{ + Component: "vpc", + FilterByStack: "dev-us-east-1", + } + + result, err := exec.executeForComponent(&schema.AtmosConfiguration{}, args) + require.NoError(t, err) + + // Verify component output uses Atmos schema format. + // Expected: components: { terraform: { vpc: { locals: {...} } } } + assert.Contains(t, result, "components", "component output should have 'components' key") + + components, ok := result["components"].(map[string]any) + require.True(t, ok) + terraform, ok := components["terraform"].(map[string]any) + require.True(t, ok) + vpc, ok := terraform["vpc"].(map[string]any) + require.True(t, ok) + assert.Contains(t, vpc, "locals", "component output should have 'locals' key") + + // Verify locals contain merged values. + locals, ok := vpc["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme", locals["namespace"]) + assert.Equal(t, "acme-tfstate", locals["backend_bucket"]) + }) + + t.Run("component output works with direct format", func(t *testing.T) { + exec := &describeLocalsExec{ + executeDescribeComponent: func(params *ExecuteDescribeComponentParams) (map[string]any, error) { + // Filter should be "deploy/prod" (file path). + assert.Equal(t, "deploy/prod", params.Stack) + return map[string]any{ + "component_type": "terraform", + }, nil + }, + executeDescribeLocals: func(ac *schema.AtmosConfiguration, filterByStack string) (map[string]any, error) { + // New direct format (no stack name wrapper). + return map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + }, + }, nil + }, + } + + args := &DescribeLocalsArgs{ + Component: "vpc", + FilterByStack: "deploy/prod", + } + + result, err := exec.executeForComponent(&schema.AtmosConfiguration{}, args) + require.NoError(t, err) + + // Verify component output uses Atmos schema format. + assert.Contains(t, result, "components") + components, ok := result["components"].(map[string]any) + require.True(t, ok) + terraform, ok := components["terraform"].(map[string]any) + require.True(t, ok) + assert.Contains(t, terraform, "vpc") + }) +} + +func TestDescribeLocalsExecExecute(t *testing.T) { + t.Run("execute requires stack", func(t *testing.T) { + exec := &describeLocalsExec{} + + args := &DescribeLocalsArgs{ + Format: "yaml", + // FilterByStack is empty - should error. + } + + err := exec.Execute(&schema.AtmosConfiguration{}, args) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrStackRequired) + }) + + t.Run("execute with stack", func(t *testing.T) { + // Create a temporary directory for test files. + tempDir := t.TempDir() + + devYAML := ` +locals: + namespace: acme +` + devFile := filepath.Join(tempDir, "dev.yaml") + err := os.WriteFile(devFile, []byte(devYAML), 0o644) + require.NoError(t, err) + + atmosConfig := &schema.AtmosConfiguration{ + StacksBaseAbsolutePath: tempDir, + StackConfigFilesAbsolutePaths: []string{devFile}, + } + + // Create a custom exec with mocked dependencies. + exec := &describeLocalsExec{ + executeDescribeLocals: ExecuteDescribeLocals, + isTTYSupportForStdout: func() bool { return false }, + printOrWriteToFile: func(ac *schema.AtmosConfiguration, format string, file string, data any) error { + return nil + }, + } + + args := &DescribeLocalsArgs{ + Format: "yaml", + FilterByStack: "dev", + } + + err = exec.Execute(atmosConfig, args) + require.NoError(t, err) + }) + + t.Run("execute with query", func(t *testing.T) { + tempDir := t.TempDir() + + devYAML := ` +locals: + namespace: acme + environment: dev +` + devFile := filepath.Join(tempDir, "dev.yaml") + err := os.WriteFile(devFile, []byte(devYAML), 0o644) + require.NoError(t, err) + + atmosConfig := &schema.AtmosConfiguration{ + StacksBaseAbsolutePath: tempDir, + StackConfigFilesAbsolutePaths: []string{devFile}, + } + + exec := &describeLocalsExec{ + executeDescribeLocals: ExecuteDescribeLocals, + isTTYSupportForStdout: func() bool { return false }, + printOrWriteToFile: func(ac *schema.AtmosConfiguration, format string, file string, data any) error { + return nil + }, + } + + args := &DescribeLocalsArgs{ + Format: "yaml", + FilterByStack: "dev", + Query: ".dev.locals.namespace", + } + + err = exec.Execute(atmosConfig, args) + require.NoError(t, err) + }) + + t.Run("execute with file output", func(t *testing.T) { + tempDir := t.TempDir() + + devYAML := ` +locals: + namespace: acme +` + devFile := filepath.Join(tempDir, "dev.yaml") + err := os.WriteFile(devFile, []byte(devYAML), 0o644) + require.NoError(t, err) + + atmosConfig := &schema.AtmosConfiguration{ + StacksBaseAbsolutePath: tempDir, + StackConfigFilesAbsolutePaths: []string{devFile}, + } + + outputFile := filepath.Join(tempDir, "output.json") + + exec := &describeLocalsExec{ + executeDescribeLocals: ExecuteDescribeLocals, + isTTYSupportForStdout: func() bool { return false }, + printOrWriteToFile: func(ac *schema.AtmosConfiguration, format string, file string, data any) error { + return nil + }, + } + + args := &DescribeLocalsArgs{ + Format: "json", + FilterByStack: "dev", + File: outputFile, + } + + err = exec.Execute(atmosConfig, args) + require.NoError(t, err) + }) + + t.Run("execute returns error from executeDescribeLocals", func(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + expectedErr := errors.New("execute error") + + exec := &describeLocalsExec{ + executeDescribeLocals: func(ac *schema.AtmosConfiguration, filterByStack string) (map[string]any, error) { + return nil, expectedErr + }, + isTTYSupportForStdout: func() bool { return false }, + printOrWriteToFile: func(ac *schema.AtmosConfiguration, format string, file string, data any) error { + return nil + }, + } + + args := &DescribeLocalsArgs{ + Format: "yaml", + FilterByStack: "dev", + } + + err := exec.Execute(atmosConfig, args) + assert.ErrorIs(t, err, expectedErr) + }) + + t.Run("execute with component argument", func(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + var capturedData any + + exec := &describeLocalsExec{ + executeDescribeComponent: func(params *ExecuteDescribeComponentParams) (map[string]any, error) { + return map[string]any{ + "component_type": "terraform", + }, nil + }, + executeDescribeLocals: func(ac *schema.AtmosConfiguration, filterByStack string) (map[string]any, error) { + // New direct format (no stack name wrapper). + return map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + }, + "terraform": map[string]any{ + "locals": map[string]any{ + "backend_bucket": "acme-dev-tfstate", + }, + }, + }, nil + }, + isTTYSupportForStdout: func() bool { return false }, + printOrWriteToFile: func(ac *schema.AtmosConfiguration, format string, file string, data any) error { + capturedData = data + return nil + }, + } + + args := &DescribeLocalsArgs{ + Component: "vpc", + FilterByStack: "dev", + Format: "yaml", + } + + err := exec.Execute(atmosConfig, args) + require.NoError(t, err) + + // Verify the output structure uses Atmos schema format. + result, ok := capturedData.(map[string]any) + require.True(t, ok) + // New format: components: { terraform: { vpc: { locals: {...} } } } + components, ok := result["components"].(map[string]any) + require.True(t, ok) + terraform, ok := components["terraform"].(map[string]any) + require.True(t, ok) + vpc, ok := terraform["vpc"].(map[string]any) + require.True(t, ok) + assert.Contains(t, vpc, "locals") + }) + + t.Run("execute with component but missing stack returns error", func(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + + exec := &describeLocalsExec{ + isTTYSupportForStdout: func() bool { return false }, + printOrWriteToFile: func(ac *schema.AtmosConfiguration, format string, file string, data any) error { + return nil + }, + } + + args := &DescribeLocalsArgs{ + Component: "vpc", + // FilterByStack is empty - should error. + Format: "yaml", + } + + err := exec.Execute(atmosConfig, args) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrStackRequired) + }) +} + +func TestGetComponentType(t *testing.T) { + tests := []struct { + name string + componentSection map[string]any + expected string + }{ + { + name: "returns terraform for terraform type", + componentSection: map[string]any{"component_type": "terraform"}, + expected: "terraform", + }, + { + name: "returns helmfile for helmfile type", + componentSection: map[string]any{"component_type": "helmfile"}, + expected: "helmfile", + }, + { + name: "returns packer for packer type", + componentSection: map[string]any{"component_type": "packer"}, + expected: "packer", + }, + { + name: "defaults to terraform when not set", + componentSection: map[string]any{}, + expected: "terraform", + }, + { + name: "defaults to terraform for nil map", + componentSection: nil, + expected: "terraform", + }, + { + name: "defaults to terraform for non-string type", + componentSection: map[string]any{"component_type": 123}, + expected: "terraform", + }, + { + name: "defaults to terraform for empty string", + componentSection: map[string]any{"component_type": ""}, + expected: "terraform", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getComponentType(tt.componentSection) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractComponentLocals(t *testing.T) { + tests := []struct { + name string + componentSection map[string]any + expected map[string]any + }{ + { + name: "extracts locals from component section", + componentSection: map[string]any{ + "locals": map[string]any{ + "key1": "value1", + "key2": "value2", + }, + }, + expected: map[string]any{ + "key1": "value1", + "key2": "value2", + }, + }, + { + name: "returns nil when no locals", + componentSection: map[string]any{}, + expected: nil, + }, + { + name: "returns nil for nil section", + componentSection: nil, + expected: nil, + }, + { + name: "returns nil when locals is not a map", + componentSection: map[string]any{ + "locals": "not a map", + }, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractComponentLocals(tt.componentSection) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildComponentSchemaOutput(t *testing.T) { + tests := []struct { + name string + component string + componentType string + locals map[string]any + }{ + { + name: "builds terraform component output", + component: "vpc", + componentType: "terraform", + locals: map[string]any{"namespace": "acme"}, + }, + { + name: "builds helmfile component output", + component: "nginx", + componentType: "helmfile", + locals: map[string]any{"release": "v1"}, + }, + { + name: "builds output with empty locals", + component: "test", + componentType: "terraform", + locals: map[string]any{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildComponentSchemaOutput(tt.component, tt.componentType, tt.locals) + + // Verify structure: components -> componentType -> component -> locals. + components, ok := result["components"].(map[string]any) + require.True(t, ok) + typeSection, ok := components[tt.componentType].(map[string]any) + require.True(t, ok) + compSection, ok := typeSection[tt.component].(map[string]any) + require.True(t, ok) + assert.Contains(t, compSection, "locals") + }) + } +} + +func TestMergeLocals(t *testing.T) { + tests := []struct { + name string + base map[string]any + override map[string]any + expected map[string]any + }{ + { + name: "merges two maps", + base: map[string]any{"key1": "value1"}, + override: map[string]any{"key2": "value2"}, + expected: map[string]any{"key1": "value1", "key2": "value2"}, + }, + { + name: "override takes precedence", + base: map[string]any{"key": "base"}, + override: map[string]any{"key": "override"}, + expected: map[string]any{"key": "override"}, + }, + { + name: "deep merges nested maps", + base: map[string]any{"nested": map[string]any{"a": 1, "b": 2}}, + override: map[string]any{"nested": map[string]any{"b": 3, "c": 4}}, + expected: map[string]any{"nested": map[string]any{"a": 1, "b": 3, "c": 4}}, + }, + { + name: "handles nil base", + base: nil, + override: map[string]any{"key": "value"}, + expected: map[string]any{"key": "value"}, + }, + { + name: "handles nil override", + base: map[string]any{"key": "value"}, + override: nil, + expected: map[string]any{"key": "value"}, + }, + { + name: "handles both nil", + base: nil, + override: nil, + expected: map[string]any{}, + }, + { + name: "handles empty maps", + base: map[string]any{}, + override: map[string]any{}, + expected: map[string]any{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mergeLocals(tt.base, tt.override) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseStackFileYAML(t *testing.T) { + tempDir := t.TempDir() + + t.Run("parses valid YAML", func(t *testing.T) { + validYAML := ` +locals: + key: value +` + validFile := filepath.Join(tempDir, "valid.yaml") + err := os.WriteFile(validFile, []byte(validYAML), 0o644) + require.NoError(t, err) + + result, err := parseStackFileYAML(validFile, false) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Contains(t, result, "locals") + }) + + t.Run("returns error for file not found", func(t *testing.T) { + missingFile := filepath.Join(tempDir, "does-not-exist.yaml") + _, err := parseStackFileYAML(missingFile, false) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrInvalidStackManifest) + }) + + t.Run("returns nil for invalid YAML when not filtering", func(t *testing.T) { + invalidYAML := `invalid: yaml: [broken` + invalidFile := filepath.Join(tempDir, "invalid.yaml") + err := os.WriteFile(invalidFile, []byte(invalidYAML), 0o644) + require.NoError(t, err) + + result, err := parseStackFileYAML(invalidFile, false) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("returns error for invalid YAML when filtering", func(t *testing.T) { + invalidYAML := `invalid: yaml: [broken` + invalidFile := filepath.Join(tempDir, "invalid_filter.yaml") + err := os.WriteFile(invalidFile, []byte(invalidYAML), 0o644) + require.NoError(t, err) + + _, err = parseStackFileYAML(invalidFile, true) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrInvalidStackManifest) + }) + + t.Run("returns nil for empty file", func(t *testing.T) { + emptyFile := filepath.Join(tempDir, "empty.yaml") + err := os.WriteFile(emptyFile, []byte(""), 0o644) + require.NoError(t, err) + + result, err := parseStackFileYAML(emptyFile, false) + require.NoError(t, err) + assert.Nil(t, result) + }) +} + +func TestStackMatchesFilter(t *testing.T) { + tests := []struct { + name string + filterByStack string + stackFileName string + stackName string + expected bool + }{ + { + name: "matches by filename", + filterByStack: "deploy/dev", + stackFileName: "deploy/dev", + stackName: "dev-us-east-1", + expected: true, + }, + { + name: "matches by derived name", + filterByStack: "dev-us-east-1", + stackFileName: "deploy/dev", + stackName: "dev-us-east-1", + expected: true, + }, + { + name: "no match", + filterByStack: "prod", + stackFileName: "deploy/dev", + stackName: "dev-us-east-1", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := stackMatchesFilter(tt.filterByStack, tt.stackFileName, tt.stackName) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetSectionOnlyLocals(t *testing.T) { + tests := []struct { + name string + sectionLocals map[string]any + globalLocals map[string]any + expected map[string]any + }{ + { + name: "returns section-only keys", + sectionLocals: map[string]any{"global": "value", "section_only": "value2"}, + globalLocals: map[string]any{"global": "value"}, + expected: map[string]any{"section_only": "value2"}, + }, + { + name: "returns empty when all keys are global", + sectionLocals: map[string]any{"global": "value"}, + globalLocals: map[string]any{"global": "value"}, + expected: map[string]any{}, + }, + { + name: "returns all when no global", + sectionLocals: map[string]any{"key1": "value1", "key2": "value2"}, + globalLocals: map[string]any{}, + expected: map[string]any{"key1": "value1", "key2": "value2"}, + }, + { + name: "handles nil section", + sectionLocals: nil, + globalLocals: map[string]any{"key": "value"}, + expected: map[string]any{}, + }, + { + name: "handles nil global", + sectionLocals: map[string]any{"key": "value"}, + globalLocals: nil, + expected: map[string]any{"key": "value"}, + }, + { + name: "excludes keys with same value", + sectionLocals: map[string]any{"same": "value", "different": "new"}, + globalLocals: map[string]any{"same": "value", "different": "old"}, + expected: map[string]any{"different": "new"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getSectionOnlyLocals(tt.sectionLocals, tt.globalLocals) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestValuesEqual(t *testing.T) { + tests := []struct { + name string + a any + b any + expected bool + }{ + { + name: "equal strings", + a: "value", + b: "value", + expected: true, + }, + { + name: "different strings", + a: "value1", + b: "value2", + expected: false, + }, + { + name: "equal ints", + a: 123, + b: 123, + expected: true, + }, + { + name: "different ints", + a: 123, + b: 456, + expected: false, + }, + { + name: "equal maps", + a: map[string]any{"key": "value"}, + b: map[string]any{"key": "value"}, + expected: true, + }, + { + name: "different maps", + a: map[string]any{"key": "value1"}, + b: map[string]any{"key": "value2"}, + expected: false, + }, + { + name: "equal slices", + a: []any{1, 2, 3}, + b: []any{1, 2, 3}, + expected: true, + }, + { + name: "different slices", + a: []any{1, 2, 3}, + b: []any{1, 2, 4}, + expected: false, + }, + { + name: "nil values", + a: nil, + b: nil, + expected: true, + }, + { + name: "one nil", + a: "value", + b: nil, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := valuesEqual(tt.a, tt.b) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetExplicitStackName(t *testing.T) { + tests := []struct { + name string + stackSectionMap map[string]any + expected string + }{ + { + name: "returns explicit name", + stackSectionMap: map[string]any{"name": "my-stack"}, + expected: "my-stack", + }, + { + name: "returns empty for missing name", + stackSectionMap: map[string]any{}, + expected: "", + }, + { + name: "returns empty for nil map", + stackSectionMap: nil, + expected: "", + }, + { + name: "returns empty for non-string name", + stackSectionMap: map[string]any{"name": 123}, + expected: "", + }, + { + name: "returns empty string name as-is", + stackSectionMap: map[string]any{"name": ""}, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getExplicitStackName(tt.stackSectionMap) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestBuildComponentLocalsResult(t *testing.T) { + t.Run("direct format with locals succeeds", func(t *testing.T) { + args := &DescribeLocalsArgs{ + Component: "vpc", + FilterByStack: "dev", + } + // New direct format (no stack name wrapper). + stackLocals := map[string]any{ + "locals": map[string]any{"namespace": "acme"}, + } + + result, err := buildComponentLocalsResult(args, stackLocals, "terraform", nil) + require.NoError(t, err) + assert.Contains(t, result, "components") + }) + + t.Run("direct format with section-specific locals", func(t *testing.T) { + args := &DescribeLocalsArgs{ + Component: "vpc", + FilterByStack: "deploy/prod", + } + // New direct format with terraform section. + stackLocals := map[string]any{ + "locals": map[string]any{"namespace": "acme"}, + "terraform": map[string]any{ + "locals": map[string]any{"backend_bucket": "acme-tfstate"}, + }, + } + + result, err := buildComponentLocalsResult(args, stackLocals, "terraform", nil) + require.NoError(t, err) + assert.Contains(t, result, "components") + + // Verify merged locals include both global and terraform-specific. + components, ok := result["components"].(map[string]any) + require.True(t, ok) + terraform, ok := components["terraform"].(map[string]any) + require.True(t, ok) + vpc, ok := terraform["vpc"].(map[string]any) + require.True(t, ok) + locals, ok := vpc["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme", locals["namespace"]) + assert.Equal(t, "acme-tfstate", locals["backend_bucket"]) + }) + + t.Run("uses component locals when stack has no locals", func(t *testing.T) { + args := &DescribeLocalsArgs{ + Component: "vpc", + FilterByStack: "dev", + } + componentLocals := map[string]any{"component_key": "value"} + + result, err := buildComponentLocalsResult(args, map[string]any{}, "terraform", componentLocals) + require.NoError(t, err) + + components, ok := result["components"].(map[string]any) + require.True(t, ok) + terraform, ok := components["terraform"].(map[string]any) + require.True(t, ok) + vpc, ok := terraform["vpc"].(map[string]any) + require.True(t, ok) + locals, ok := vpc["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "value", locals["component_key"]) + }) + + t.Run("returns error when no locals available", func(t *testing.T) { + args := &DescribeLocalsArgs{ + Component: "vpc", + FilterByStack: "dev", + } + + _, err := buildComponentLocalsResult(args, map[string]any{}, "terraform", nil) + assert.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrStackHasNoLocals) + }) +} + +func TestExecuteDescribeLocalsWindowsPathNormalization(t *testing.T) { + // Test that Windows-style paths are normalized. + tempDir := t.TempDir() + + devYAML := ` +locals: + namespace: acme +` + // Create nested directory structure. + deployDir := filepath.Join(tempDir, "deploy") + err := os.MkdirAll(deployDir, 0o755) + require.NoError(t, err) + + devFile := filepath.Join(deployDir, "dev.yaml") + err = os.WriteFile(devFile, []byte(devYAML), 0o644) + require.NoError(t, err) + + atmosConfig := &schema.AtmosConfiguration{ + StacksBaseAbsolutePath: tempDir, + StackConfigFilesAbsolutePaths: []string{devFile}, + } + + // Use forward slashes (as would be normalized from Windows backslashes). + result, err := ExecuteDescribeLocals(atmosConfig, "deploy/dev") + require.NoError(t, err) + // Result should be in direct format (locals: {...}). + assert.Contains(t, result, "locals") + locals, ok := result["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme", locals["namespace"]) +} diff --git a/internal/exec/mock_describe_locals.go b/internal/exec/mock_describe_locals.go new file mode 100644 index 0000000000..4e02f7d194 --- /dev/null +++ b/internal/exec/mock_describe_locals.go @@ -0,0 +1,55 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: describe_locals.go +// +// Generated by this command: +// +// mockgen -source=describe_locals.go -destination=mock_describe_locals.go -package=exec +// + +// Package exec is a generated GoMock package. +package exec + +import ( + reflect "reflect" + + schema "github.com/cloudposse/atmos/pkg/schema" + gomock "go.uber.org/mock/gomock" +) + +// MockDescribeLocalsExec is a mock of DescribeLocalsExec interface. +type MockDescribeLocalsExec struct { + ctrl *gomock.Controller + recorder *MockDescribeLocalsExecMockRecorder + isgomock struct{} +} + +// MockDescribeLocalsExecMockRecorder is the mock recorder for MockDescribeLocalsExec. +type MockDescribeLocalsExecMockRecorder struct { + mock *MockDescribeLocalsExec +} + +// NewMockDescribeLocalsExec creates a new mock instance. +func NewMockDescribeLocalsExec(ctrl *gomock.Controller) *MockDescribeLocalsExec { + mock := &MockDescribeLocalsExec{ctrl: ctrl} + mock.recorder = &MockDescribeLocalsExecMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDescribeLocalsExec) EXPECT() *MockDescribeLocalsExecMockRecorder { + return m.recorder +} + +// Execute mocks base method. +func (m *MockDescribeLocalsExec) Execute(atmosConfig *schema.AtmosConfiguration, args *DescribeLocalsArgs) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Execute", atmosConfig, args) + ret0, _ := ret[0].(error) + return ret0 +} + +// Execute indicates an expected call of Execute. +func (mr *MockDescribeLocalsExecMockRecorder) Execute(atmosConfig, args any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Execute", reflect.TypeOf((*MockDescribeLocalsExec)(nil).Execute), atmosConfig, args) +} diff --git a/internal/exec/stack_processor_locals.go b/internal/exec/stack_processor_locals.go index c4e6996355..fddb0a624c 100644 --- a/internal/exec/stack_processor_locals.go +++ b/internal/exec/stack_processor_locals.go @@ -82,7 +82,6 @@ func resolveLocalsWithDependencies(localsMap, parentLocals map[string]any, fileP // ProcessStackLocals extracts and resolves all locals from a stack config file. // Returns a LocalsContext with resolved locals at each scope (global, terraform, helmfile, packer). -// Component-level locals are processed separately during component processing. func ProcessStackLocals( atmosConfig *schema.AtmosConfiguration, stackConfigMap map[string]any, @@ -106,6 +105,10 @@ func ProcessStackLocals( return nil, fmt.Errorf("failed to resolve terraform locals: %w", err) } ctx.Terraform = terraformLocals + // Check if terraform section has its own locals key. + if _, hasLocals := terraformSection[cfg.LocalsSectionName]; hasLocals { + ctx.HasTerraformLocals = true + } } else { ctx.Terraform = ctx.Global } @@ -117,6 +120,10 @@ func ProcessStackLocals( return nil, fmt.Errorf("failed to resolve helmfile locals: %w", err) } ctx.Helmfile = helmfileLocals + // Check if helmfile section has its own locals key. + if _, hasLocals := helmfileSection[cfg.LocalsSectionName]; hasLocals { + ctx.HasHelmfileLocals = true + } } else { ctx.Helmfile = ctx.Global } @@ -128,6 +135,10 @@ func ProcessStackLocals( return nil, fmt.Errorf("failed to resolve packer locals: %w", err) } ctx.Packer = packerLocals + // Check if packer section has its own locals key. + if _, hasLocals := packerSection[cfg.LocalsSectionName]; hasLocals { + ctx.HasPackerLocals = true + } } else { ctx.Packer = ctx.Global } @@ -149,11 +160,21 @@ type LocalsContext struct { // Packer holds locals from the packer section (merged with global). Packer map[string]any + + // HasTerraformLocals indicates the terraform section has its own locals defined. + HasTerraformLocals bool + + // HasHelmfileLocals indicates the helmfile section has its own locals defined. + HasHelmfileLocals bool + + // HasPackerLocals indicates the packer section has its own locals defined. + HasPackerLocals bool } -// GetForComponentType returns the appropriate locals for a given component type. -func (ctx *LocalsContext) GetForComponentType(componentType string) map[string]any { - defer perf.Track(nil, "exec.LocalsContext.GetForComponentType")() +// MergeForComponentType returns the merged locals for a specific component type. +// This is what templates would see for a component of that type. +func (ctx *LocalsContext) MergeForComponentType(componentType string) map[string]any { + defer perf.Track(nil, "exec.LocalsContext.MergeForComponentType")() if ctx == nil { return nil @@ -167,12 +188,63 @@ func (ctx *LocalsContext) GetForComponentType(componentType string) map[string]a case cfg.PackerSectionName: return ctx.Packer default: + // For unknown types, return global only. return ctx.Global } } -// ResolveComponentLocals resolves locals for a specific component. -// It merges component-level locals with the parent scope (component-type or global). +// MergeForTemplateContext merges all locals into a single flat map for template processing. +// Global locals are copied first, then section-specific locals override if explicitly defined. +// +// Precedence (later overrides earlier): Global → Terraform → Helmfile → Packer. +// +// Note: In practice, overlapping keys across sections is uncommon because components are +// single-typed (a component is either terraform, helmfile, or packer, not multiple). +// For component-specific processing, use MergeForComponentType instead, which only merges +// the relevant section for the component's type. +func (ctx *LocalsContext) MergeForTemplateContext() map[string]any { + defer perf.Track(nil, "exec.LocalsContext.MergeForTemplateContext")() + + if ctx == nil { + return nil + } + + result := make(map[string]any) + + // Copy global locals first. + for k, v := range ctx.Global { + result[k] = v + } + + // Merge section-specific locals only if explicitly defined. + // Precedence: terraform → helmfile → packer (last wins for overlapping keys). + ctx.mergeSectionLocals(result, ctx.Terraform, ctx.HasTerraformLocals) + ctx.mergeSectionLocals(result, ctx.Helmfile, ctx.HasHelmfileLocals) + ctx.mergeSectionLocals(result, ctx.Packer, ctx.HasPackerLocals) + + return result +} + +// mergeSectionLocals merges section locals into the result map if hasLocals is true. +func (ctx *LocalsContext) mergeSectionLocals(result, sectionLocals map[string]any, hasLocals bool) { + if !hasLocals { + return + } + for k, v := range sectionLocals { + result[k] = v + } +} + +// GetForComponentType returns the appropriate locals for a given component type. +// This is an alias for MergeForComponentType for API compatibility. +func (ctx *LocalsContext) GetForComponentType(componentType string) map[string]any { + defer perf.Track(nil, "exec.LocalsContext.GetForComponentType")() + + return ctx.MergeForComponentType(componentType) +} + +// ResolveComponentLocals resolves locals from a config section and merges with parent locals. +// This is used for component-level locals which inherit from stack-level locals and base components. func ResolveComponentLocals( atmosConfig *schema.AtmosConfiguration, componentConfig map[string]any, diff --git a/internal/exec/stack_processor_locals_test.go b/internal/exec/stack_processor_locals_test.go index 84c5a9f7b5..2aebfd9a50 100644 --- a/internal/exec/stack_processor_locals_test.go +++ b/internal/exec/stack_processor_locals_test.go @@ -529,3 +529,374 @@ func TestCopyOrCreateParentLocals_WithData(t *testing.T) { result["key1"] = "modified" assert.Equal(t, "value1", parentLocals["key1"]) } + +// ============================================================================= +// File-Scoped Locals Unit Tests +// ============================================================================= + +// TestLocalsContext_MergeForTemplateContext verifies the merge behavior for template context. +func TestLocalsContext_MergeForTemplateContext(t *testing.T) { + ctx := &LocalsContext{ + Global: map[string]any{ + "namespace": "global-ns", + "environment": "global-env", + }, + Terraform: map[string]any{ + "namespace": "terraform-ns", + "backend_bucket": "tf-bucket", + }, + Helmfile: map[string]any{ + "namespace": "helmfile-ns", + "release_name": "hf-release", + }, + Packer: map[string]any{ + "namespace": "packer-ns", + "image_name": "pk-image", + }, + HasTerraformLocals: true, + HasHelmfileLocals: true, + HasPackerLocals: true, + } + + merged := ctx.MergeForTemplateContext() + + // Packer (last) should win for namespace since all sections define it. + assert.Equal(t, "packer-ns", merged["namespace"]) + + // Section-specific values should be present. + assert.Equal(t, "tf-bucket", merged["backend_bucket"]) + assert.Equal(t, "hf-release", merged["release_name"]) + assert.Equal(t, "pk-image", merged["image_name"]) + + // Global-only values should be present. + assert.Equal(t, "global-env", merged["environment"]) +} + +// TestLocalsContext_MergeForTemplateContext_OnlyGlobal verifies merge with only global locals. +func TestLocalsContext_MergeForTemplateContext_OnlyGlobal(t *testing.T) { + ctx := &LocalsContext{ + Global: map[string]any{ + "namespace": "global-ns", + }, + // No section-specific locals (flags are false). + HasTerraformLocals: false, + HasHelmfileLocals: false, + HasPackerLocals: false, + } + + merged := ctx.MergeForTemplateContext() + + assert.Equal(t, "global-ns", merged["namespace"]) + assert.Len(t, merged, 1) +} + +// TestLocalsContext_MergeForTemplateContext_Nil verifies nil context returns nil. +func TestLocalsContext_MergeForTemplateContext_Nil(t *testing.T) { + var ctx *LocalsContext + merged := ctx.MergeForTemplateContext() + assert.Nil(t, merged) +} + +// TestLocalsContext_MergeForTemplateContext_EmptyGlobal verifies empty global with sections. +func TestLocalsContext_MergeForTemplateContext_EmptyGlobal(t *testing.T) { + ctx := &LocalsContext{ + Global: map[string]any{}, + Terraform: map[string]any{ + "tf_var": "tf-value", + }, + HasTerraformLocals: true, + } + + merged := ctx.MergeForTemplateContext() + + assert.Equal(t, "tf-value", merged["tf_var"]) +} + +// TestProcessStackLocals_SectionLocalsOverrideGlobal verifies section locals override global. +func TestProcessStackLocals_SectionLocalsOverrideGlobal(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "locals": map[string]any{ + "namespace": "global-namespace", + "shared": "global-shared", + }, + "terraform": map[string]any{ + "locals": map[string]any{ + "namespace": "terraform-namespace", + }, + }, + } + + ctx, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, ctx) + + // Global should have original value. + assert.Equal(t, "global-namespace", ctx.Global["namespace"]) + assert.Equal(t, "global-shared", ctx.Global["shared"]) + + // Terraform should have overridden namespace but inherit shared. + assert.Equal(t, "terraform-namespace", ctx.Terraform["namespace"]) + assert.Equal(t, "global-shared", ctx.Terraform["shared"]) +} + +// TestProcessStackLocals_HasFlagsSetCorrectly verifies Has*Locals flags are set. +func TestProcessStackLocals_HasFlagsSetCorrectly(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + + tests := []struct { + name string + stackConfig map[string]any + expectTerraform bool + expectHelmfile bool + expectPacker bool + }{ + { + name: "only terraform locals", + stackConfig: map[string]any{ + "terraform": map[string]any{ + "locals": map[string]any{"key": "value"}, + }, + }, + expectTerraform: true, + expectHelmfile: false, + expectPacker: false, + }, + { + name: "only helmfile locals", + stackConfig: map[string]any{ + "helmfile": map[string]any{ + "locals": map[string]any{"key": "value"}, + }, + }, + expectTerraform: false, + expectHelmfile: true, + expectPacker: false, + }, + { + name: "only packer locals", + stackConfig: map[string]any{ + "packer": map[string]any{ + "locals": map[string]any{"key": "value"}, + }, + }, + expectTerraform: false, + expectHelmfile: false, + expectPacker: true, + }, + { + name: "all sections with locals", + stackConfig: map[string]any{ + "terraform": map[string]any{ + "locals": map[string]any{"key": "value"}, + }, + "helmfile": map[string]any{ + "locals": map[string]any{"key": "value"}, + }, + "packer": map[string]any{ + "locals": map[string]any{"key": "value"}, + }, + }, + expectTerraform: true, + expectHelmfile: true, + expectPacker: true, + }, + { + name: "sections without locals key", + stackConfig: map[string]any{ + "terraform": map[string]any{ + "vars": map[string]any{"key": "value"}, + }, + "helmfile": map[string]any{ + "vars": map[string]any{"key": "value"}, + }, + }, + expectTerraform: false, + expectHelmfile: false, + expectPacker: false, + }, + { + name: "empty locals section sets flag", + stackConfig: map[string]any{ + "terraform": map[string]any{ + "locals": map[string]any{}, // Empty locals section. + }, + }, + expectTerraform: true, // Flag is set based on key presence, not content. + expectHelmfile: false, + expectPacker: false, + }, + { + name: "all sections with empty locals", + stackConfig: map[string]any{ + "terraform": map[string]any{ + "locals": map[string]any{}, + }, + "helmfile": map[string]any{ + "locals": map[string]any{}, + }, + "packer": map[string]any{ + "locals": map[string]any{}, + }, + }, + expectTerraform: true, + expectHelmfile: true, + expectPacker: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, err := ProcessStackLocals(atmosConfig, tt.stackConfig, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, ctx) + + assert.Equal(t, tt.expectTerraform, ctx.HasTerraformLocals, "HasTerraformLocals mismatch") + assert.Equal(t, tt.expectHelmfile, ctx.HasHelmfileLocals, "HasHelmfileLocals mismatch") + assert.Equal(t, tt.expectPacker, ctx.HasPackerLocals, "HasPackerLocals mismatch") + }) + } +} + +// TestExtractAndResolveLocals_NestedTemplateReferences tests deeply nested template references. +func TestExtractAndResolveLocals_NestedTemplateReferences(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + section := map[string]any{ + "locals": map[string]any{ + "a": "base", + "b": "{{ .locals.a }}-level1", + "c": "{{ .locals.b }}-level2", + "d": "{{ .locals.c }}-level3", + "final": "{{ .locals.d }}-final", + }, + } + + result, err := ExtractAndResolveLocals(atmosConfig, section, nil, "test.yaml") + + require.NoError(t, err) + assert.Equal(t, "base", result["a"]) + assert.Equal(t, "base-level1", result["b"]) + assert.Equal(t, "base-level1-level2", result["c"]) + assert.Equal(t, "base-level1-level2-level3", result["d"]) + assert.Equal(t, "base-level1-level2-level3-final", result["final"]) +} + +// TestExtractAndResolveLocals_MixedStaticAndTemplateValues tests mixed values. +func TestExtractAndResolveLocals_MixedStaticAndTemplateValues(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + section := map[string]any{ + "locals": map[string]any{ + "static_string": "hello", + "static_int": 42, + "static_bool": true, + "static_list": []any{"a", "b", "c"}, + "template_val": "{{ .locals.static_string }}-world", + }, + } + + result, err := ExtractAndResolveLocals(atmosConfig, section, nil, "test.yaml") + + require.NoError(t, err) + assert.Equal(t, "hello", result["static_string"]) + assert.Equal(t, 42, result["static_int"]) + assert.Equal(t, true, result["static_bool"]) + assert.Equal(t, []any{"a", "b", "c"}, result["static_list"]) + assert.Equal(t, "hello-world", result["template_val"]) +} + +// TestExtractAndResolveLocals_ParentLocalsNotModified verifies parent locals are not modified. +func TestExtractAndResolveLocals_ParentLocalsNotModified(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + parentLocals := map[string]any{ + "parent_key": "parent_value", + } + section := map[string]any{ + "locals": map[string]any{ + "parent_key": "child_override", + "child_key": "child_value", + }, + } + + result, err := ExtractAndResolveLocals(atmosConfig, section, parentLocals, "test.yaml") + + require.NoError(t, err) + + // Result should have overridden value. + assert.Equal(t, "child_override", result["parent_key"]) + assert.Equal(t, "child_value", result["child_key"]) + + // Parent locals should NOT be modified. + assert.Equal(t, "parent_value", parentLocals["parent_key"]) + assert.NotContains(t, parentLocals, "child_key") +} + +// TestProcessStackLocals_IsolationBetweenSections verifies sections don't affect each other. +func TestProcessStackLocals_IsolationBetweenSections(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "locals": map[string]any{ + "shared": "global", + }, + "terraform": map[string]any{ + "locals": map[string]any{ + "tf_only": "terraform-value", + }, + }, + "helmfile": map[string]any{ + "locals": map[string]any{ + "hf_only": "helmfile-value", + }, + }, + } + + ctx, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, ctx) + + // Terraform should have its own local plus global. + assert.Equal(t, "terraform-value", ctx.Terraform["tf_only"]) + assert.Equal(t, "global", ctx.Terraform["shared"]) + assert.NotContains(t, ctx.Terraform, "hf_only", "terraform should not have helmfile locals") + + // Helmfile should have its own local plus global. + assert.Equal(t, "helmfile-value", ctx.Helmfile["hf_only"]) + assert.Equal(t, "global", ctx.Helmfile["shared"]) + assert.NotContains(t, ctx.Helmfile, "tf_only", "helmfile should not have terraform locals") +} + +// TestMergeForTemplateContext_EmptyLocals verifies that merging empty locals sections has no effect. +func TestMergeForTemplateContext_EmptyLocals(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + + // Stack config with global locals and empty section locals. + stackConfig := map[string]any{ + "locals": map[string]any{ + "namespace": "acme", + "environment": "prod", + }, + "terraform": map[string]any{ + "locals": map[string]any{}, // Empty locals section. + }, + "helmfile": map[string]any{ + "locals": map[string]any{}, // Empty locals section. + }, + } + + ctx, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + require.NoError(t, err) + require.NotNil(t, ctx) + + // Flags should be set because locals key exists. + assert.True(t, ctx.HasTerraformLocals, "HasTerraformLocals should be true for empty locals") + assert.True(t, ctx.HasHelmfileLocals, "HasHelmfileLocals should be true for empty locals") + assert.False(t, ctx.HasPackerLocals, "HasPackerLocals should be false when not defined") + + // MergeForTemplateContext should return only global locals since section locals are empty. + merged := ctx.MergeForTemplateContext() + assert.Equal(t, "acme", merged["namespace"]) + assert.Equal(t, "prod", merged["environment"]) + assert.Len(t, merged, 2, "merged should only contain global locals") +} diff --git a/internal/exec/stack_processor_merge.go b/internal/exec/stack_processor_merge.go index 9878aa38c6..665bc58019 100644 --- a/internal/exec/stack_processor_merge.go +++ b/internal/exec/stack_processor_merge.go @@ -225,6 +225,22 @@ func mergeComponentConfigurations(atmosConfig *schema.AtmosConfiguration, opts * } } + // Merge locals (base component locals + component locals). + // Component locals take precedence over base component locals. + // Note: Locals are used for template processing, not passed to terraform/helmfile. + var finalComponentLocals map[string]any + if len(result.BaseComponentLocals) > 0 || len(result.ComponentLocals) > 0 { + finalComponentLocals, err = m.Merge( + atmosConfig, + []map[string]any{ + result.BaseComponentLocals, + result.ComponentLocals, + }) + if err != nil { + return nil, err + } + } + // Build final component map. comp := map[string]any{ cfg.VarsSectionName: finalComponentVars, @@ -242,6 +258,11 @@ func mergeComponentConfigurations(atmosConfig *schema.AtmosConfiguration, opts * comp[cfg.DependenciesSectionName] = finalComponentDependencies } + // Add locals if present (for template processing, not passed to terraform/helmfile). + if len(finalComponentLocals) > 0 { + comp[cfg.LocalsSectionName] = finalComponentLocals + } + // Terraform-specific: process backends and add Terraform-specific fields. if opts.ComponentType == cfg.TerraformComponentType { // Process backend configuration. diff --git a/internal/exec/stack_processor_process_stacks_helpers.go b/internal/exec/stack_processor_process_stacks_helpers.go index 9ee5654b6e..9dbd5af3a0 100644 --- a/internal/exec/stack_processor_process_stacks_helpers.go +++ b/internal/exec/stack_processor_process_stacks_helpers.go @@ -53,6 +53,7 @@ type ComponentProcessorResult struct { ComponentEnv map[string]any ComponentMetadata map[string]any ComponentDependencies map[string]any + ComponentLocals map[string]any // Component-level locals for template processing. ComponentCommand string ComponentOverrides map[string]any ComponentOverridesVars map[string]any @@ -67,6 +68,7 @@ type ComponentProcessorResult struct { BaseComponentAuth map[string]any BaseComponentMetadata map[string]any BaseComponentDependencies map[string]any + BaseComponentLocals map[string]any // Base component locals for inheritance. BaseComponentCommand string ComponentInheritanceChain []string BaseComponents []string diff --git a/internal/exec/stack_processor_process_stacks_helpers_extraction.go b/internal/exec/stack_processor_process_stacks_helpers_extraction.go index 50fac8dbee..b3fc22eea7 100644 --- a/internal/exec/stack_processor_process_stacks_helpers_extraction.go +++ b/internal/exec/stack_processor_process_stacks_helpers_extraction.go @@ -23,6 +23,15 @@ func extractComponentSections(opts *ComponentProcessorOptions, result *Component result.ComponentVars = componentVars } + // Extract locals section (for template processing, not passed to terraform/helmfile). + if i, ok := opts.ComponentMap[cfg.LocalsSectionName]; ok { + componentLocals, ok := i.(map[string]any) + if !ok { + return fmt.Errorf("%w: 'components.%s.%s.locals' in the file '%s'", errUtils.ErrInvalidComponentLocals, opts.ComponentType, opts.Component, opts.StackName) + } + result.ComponentLocals = componentLocals + } + // Extract settings section. if i, ok := opts.ComponentMap[cfg.SettingsSectionName]; ok { componentSettings, ok := i.(map[string]any) diff --git a/internal/exec/stack_processor_process_stacks_helpers_inheritance.go b/internal/exec/stack_processor_process_stacks_helpers_inheritance.go index f8568db646..ee896645a4 100644 --- a/internal/exec/stack_processor_process_stacks_helpers_inheritance.go +++ b/internal/exec/stack_processor_process_stacks_helpers_inheritance.go @@ -22,6 +22,7 @@ func processComponentInheritance(opts *ComponentProcessorOptions, result *Compon result.BaseComponentAuth = make(map[string]any, componentSmallMapCapacity) result.BaseComponentMetadata = make(map[string]any, componentSmallMapCapacity) result.BaseComponentDependencies = make(map[string]any, componentSmallMapCapacity) + result.BaseComponentLocals = make(map[string]any, componentSmallMapCapacity) if opts.ComponentType == cfg.TerraformComponentType { result.BaseComponentProviders = make(map[string]any, componentSmallMapCapacity) result.BaseComponentHooks = make(map[string]any, componentSmallMapCapacity) @@ -200,6 +201,7 @@ func applyBaseComponentConfig(opts *ComponentProcessorOptions, result *Component result.BaseComponentAuth = baseComponentConfig.BaseComponentAuth result.BaseComponentMetadata = baseComponentConfig.BaseComponentMetadata result.BaseComponentDependencies = baseComponentConfig.BaseComponentDependencies + result.BaseComponentLocals = baseComponentConfig.BaseComponentLocals result.BaseComponentName = baseComponentConfig.FinalBaseComponentName result.BaseComponentCommand = baseComponentConfig.BaseComponentCommand *componentInheritanceChain = baseComponentConfig.ComponentInheritanceChain diff --git a/internal/exec/stack_processor_process_stacks_helpers_test.go b/internal/exec/stack_processor_process_stacks_helpers_test.go index 97b7b33753..05b87b448f 100644 --- a/internal/exec/stack_processor_process_stacks_helpers_test.go +++ b/internal/exec/stack_processor_process_stacks_helpers_test.go @@ -18,6 +18,7 @@ func TestProcessComponent(t *testing.T) { expectedVars map[string]any expectedSettings map[string]any expectedEnv map[string]any + expectedLocals map[string]any expectedMetadata map[string]any expectedCommand string expectedProviders map[string]any @@ -103,6 +104,52 @@ func TestProcessComponent(t *testing.T) { }, expectedCommand: "tofu", }, + { + name: "terraform component with locals", + opts: ComponentProcessorOptions{ + ComponentType: cfg.TerraformComponentType, + Component: "vpc", + Stack: "test-stack", + StackName: "test-stack", + ComponentMap: map[string]any{ + cfg.VarsSectionName: map[string]any{ + "region": "us-east-1", + }, + cfg.LocalsSectionName: map[string]any{ + "environment": "dev", + "team": "platform", + }, + }, + AllComponentsMap: map[string]any{}, + ComponentsBasePath: "/test/components", + CheckBaseComponentExists: true, + AtmosConfig: &schema.AtmosConfiguration{}, + }, + expectedVars: map[string]any{ + "region": "us-east-1", + }, + expectedLocals: map[string]any{ + "environment": "dev", + "team": "platform", + }, + }, + { + name: "invalid locals section type", + opts: ComponentProcessorOptions{ + ComponentType: cfg.TerraformComponentType, + Component: "vpc", + Stack: "test-stack", + StackName: "test-stack", + ComponentMap: map[string]any{ + cfg.LocalsSectionName: "invalid-not-a-map", + }, + AllComponentsMap: map[string]any{}, + ComponentsBasePath: "/test/components", + CheckBaseComponentExists: true, + AtmosConfig: &schema.AtmosConfiguration{}, + }, + expectedError: "invalid component locals section", + }, { name: "helmfile component without terraform-specific sections", opts: ComponentProcessorOptions{ @@ -309,6 +356,10 @@ func TestProcessComponent(t *testing.T) { assert.Equal(t, tt.expectedEnv, result.ComponentEnv) } + if tt.expectedLocals != nil { + assert.Equal(t, tt.expectedLocals, result.ComponentLocals) + } + if tt.expectedMetadata != nil { assert.Equal(t, tt.expectedMetadata, result.ComponentMetadata) } @@ -937,6 +988,10 @@ func TestApplyBaseComponentConfig(t *testing.T) { BaseComponentEnv: map[string]any{ "AWS_REGION": "us-east-1", }, + BaseComponentLocals: map[string]any{ + "environment": "dev", + "team": "platform", + }, BaseComponentCommand: "terraform", BaseComponentProviders: map[string]any{ "aws": map[string]any{"region": "us-east-1"}, @@ -968,6 +1023,7 @@ func TestApplyBaseComponentConfig(t *testing.T) { assert.Equal(t, baseComponentConfig.BaseComponentVars, result.BaseComponentVars) assert.Equal(t, baseComponentConfig.BaseComponentSettings, result.BaseComponentSettings) assert.Equal(t, baseComponentConfig.BaseComponentEnv, result.BaseComponentEnv) + assert.Equal(t, baseComponentConfig.BaseComponentLocals, result.BaseComponentLocals) assert.Equal(t, "terraform", result.BaseComponentCommand) assert.Equal(t, baseComponentConfig.BaseComponentProviders, result.BaseComponentProviders) assert.Equal(t, baseComponentConfig.BaseComponentHooks, result.BaseComponentHooks) diff --git a/internal/exec/stack_processor_utils.go b/internal/exec/stack_processor_utils.go index a58611fe22..de942e10fe 100644 --- a/internal/exec/stack_processor_utils.go +++ b/internal/exec/stack_processor_utils.go @@ -2,6 +2,7 @@ package exec import ( "encoding/json" + stderrors "errors" "fmt" "os" "path/filepath" @@ -13,6 +14,7 @@ import ( "github.com/go-viper/mapstructure/v2" "github.com/pkg/errors" "github.com/santhosh-tekuri/jsonschema/v5" + "gopkg.in/yaml.v3" errUtils "github.com/cloudposse/atmos/errors" cfg "github.com/cloudposse/atmos/pkg/config" @@ -26,6 +28,110 @@ import ( // Mutex to serialize writes to importsConfig maps during parallel import processing. var importsConfigLock = &sync.Mutex{} +// extractLocalsFromRawYAML parses raw YAML content and extracts/resolves file-scoped locals. +// This function is called BEFORE template processing to make locals available during template execution. +// The raw YAML may contain unresolved templates like {{ .locals.X }}, which YAML treats as strings. +// The locals resolver handles resolving self-references between locals. +// Returns the resolved locals map or nil if no locals section exists. +// +// Locals are extracted and merged in order of specificity: +// 1. Global locals (root level) +// 2. Section-specific locals (terraform, helmfile, packer sections) +// +// Section-specific locals inherit from and can override global locals. +// All resolved locals are flattened into a single map for template processing. +func extractLocalsFromRawYAML(atmosConfig *schema.AtmosConfiguration, yamlContent string, filePath string) (map[string]any, error) { + defer perf.Track(atmosConfig, "exec.extractLocalsFromRawYAML")() + + // Parse raw YAML to extract the structure. + // YAML treats template expressions like {{ .locals.X }} as plain strings, + // so parsing succeeds even with unresolved templates. + var rawConfig map[string]any + if err := yaml.Unmarshal([]byte(yamlContent), &rawConfig); err != nil { + // Provide a helpful hint if the file might contain Go template directives + // that aren't valid YAML. Files with .yaml.tmpl extension are processed + // as templates first, which allows non-YAML-valid Go template syntax. + hint := "" + if !strings.HasSuffix(filePath, u.TemplateExtension) { + hint = " (hint: if this file contains Go template directives, rename it to .yaml.tmpl)" + } + return nil, fmt.Errorf("%w: failed to parse YAML for locals extraction%s: %w", errUtils.ErrInvalidStackManifest, hint, err) + } + + if rawConfig == nil { + return nil, nil + } + + // Use ProcessStackLocals which handles global and section-level scopes. + localsCtx, err := ProcessStackLocals(atmosConfig, rawConfig, filePath) + if err != nil { + return nil, fmt.Errorf("%w: failed to process stack locals: %w", errUtils.ErrInvalidStackManifest, err) + } + + return localsCtx.MergeForTemplateContext(), nil +} + +// extractAndAddLocalsToContext extracts locals from YAML and adds them to the template context. +// Returns the updated context and any error encountered during locals extraction. +// Note: The "locals" key in context is reserved for file-scoped locals and will override +// any user-provided "locals" key in the import context. +// For template files (.tmpl), YAML parse errors are logged and the function continues +// without locals, since template files may contain Go template syntax that isn't valid YAML +// until after template processing. +func extractAndAddLocalsToContext( + atmosConfig *schema.AtmosConfiguration, + yamlContent string, + filePath string, + relativeFilePath string, + context map[string]any, +) (map[string]any, error) { + defer perf.Track(atmosConfig, "exec.extractAndAddLocalsToContext")() + + // Enforce file-scoped locals: clear any inherited locals from parent context. + // Locals are file-scoped and should NOT inherit across file boundaries. + // This ensures that each file only has access to its own locals. + if context != nil { + delete(context, "locals") + } + + resolvedLocals, localsErr := extractLocalsFromRawYAML(atmosConfig, yamlContent, filePath) + if localsErr != nil { + // For template files (.tmpl), YAML parse errors are expected since the raw content + // may contain Go template syntax that isn't valid YAML until after processing. + // Log the error and continue without locals - template processing will happen next. + if strings.HasSuffix(filePath, u.TemplateExtension) { + log.Trace("Skipping locals extraction for template file with invalid YAML", "file", relativeFilePath, "error", localsErr) + return context, nil + } + // Circular dependencies in locals are a stack misconfiguration error. + // Return a helpful error with hints on how to fix it. + if stderrors.Is(localsErr, errUtils.ErrLocalsCircularDep) { + return context, errUtils.Build(errUtils.ErrLocalsCircularDep). + WithCause(localsErr). + WithContext("file", relativeFilePath). + WithHintf("Fix the circular dependency in '%s'", relativeFilePath). + WithHint("Ensure locals don't reference each other in a cycle"). + WithHintf("Use 'atmos describe locals --stack %s' to inspect locals", relativeFilePath). + WithExitCode(1). + Err() + } + return context, localsErr + } + + if len(resolvedLocals) == 0 { + return context, nil + } + + // Add resolved locals to the template context. + if context == nil { + context = make(map[string]any) + } + context["locals"] = resolvedLocals + log.Trace("Extracted and resolved locals", "file", relativeFilePath, "count", len(resolvedLocals)) + + return context, nil +} + // stackProcessResult holds the result of processing a single stack in parallel. type stackProcessResult struct { index int @@ -417,6 +523,29 @@ func processYAMLConfigFileWithContextInternal( return map[string]any{}, map[string]map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, map[string]any{}, nil, nil } + // Extract and resolve file-scoped locals before template processing. + // Locals can reference other locals using {{ .locals.X }} syntax. + // The resolved locals are added to the template context so they're available during template processing. + // This enables patterns like: + // locals: + // stage: prod + // name_prefix: "{{ .locals.stage }}-app" + // components: + // terraform: + // myapp: + // vars: + // name: "{{ .locals.name_prefix }}" + if !skipTemplatesProcessingInImports { + var localsErr error + context, localsErr = extractAndAddLocalsToContext(atmosConfig, stackYamlConfig, filePath, relativeFilePath, context) + if localsErr != nil { + if mergeContext != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, mergeContext.FormatError(localsErr, fmt.Sprintf("stack manifest '%s'", relativeFilePath)) + } + return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("stack manifest '%s': %w", relativeFilePath, localsErr) + } + } + stackManifestTemplatesProcessed := stackYamlConfig stackManifestTemplatesErrorMessage := "" @@ -1263,6 +1392,7 @@ func processBaseComponentConfigInternal( var baseComponentEnv map[string]any var baseComponentAuth map[string]any var baseComponentDependencies map[string]any + var baseComponentLocals map[string]any var baseComponentProviders map[string]any var baseComponentHooks map[string]any var baseComponentGenerate map[string]any @@ -1396,6 +1526,13 @@ func processBaseComponentConfigInternal( } } + if baseComponentLocalsSection, baseComponentLocalsSectionExist := baseComponentMap[cfg.LocalsSectionName]; baseComponentLocalsSectionExist { + baseComponentLocals, ok = baseComponentLocalsSection.(map[string]any) + if !ok { + return fmt.Errorf("%w: '%s.locals' in the stack '%s'", errUtils.ErrInvalidComponentLocals, baseComponent, stack) + } + } + if baseComponentProvidersSection, baseComponentProvidersSectionExist := baseComponentMap[cfg.ProvidersSectionName]; baseComponentProvidersSectionExist { baseComponentProviders, ok = baseComponentProvidersSection.(map[string]any) if !ok { @@ -1504,6 +1641,13 @@ func processBaseComponentConfigInternal( } baseComponentConfig.BaseComponentDependencies = merged + // Base component `locals` (for template processing, not passed to terraform/helmfile). + merged, err = m.Merge(atmosConfig, []map[string]any{baseComponentConfig.BaseComponentLocals, baseComponentLocals}) + if err != nil { + return err + } + baseComponentConfig.BaseComponentLocals = merged + // Base component `metadata` (when metadata inheritance is enabled). // Merge all metadata fields except 'inherits' and 'type'. // - 'inherits' is the meta-property defining inheritance, not inherited itself. diff --git a/internal/exec/stack_processor_utils_test.go b/internal/exec/stack_processor_utils_test.go index 5945a2eca4..ab1978c789 100644 --- a/internal/exec/stack_processor_utils_test.go +++ b/internal/exec/stack_processor_utils_test.go @@ -1,6 +1,7 @@ package exec import ( + "errors" "path/filepath" "sort" "testing" @@ -8,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + errUtils "github.com/cloudposse/atmos/errors" "github.com/cloudposse/atmos/pkg/schema" u "github.com/cloudposse/atmos/pkg/utils" ) @@ -1431,3 +1433,289 @@ func TestCacheCompiledSchema(t *testing.T) { assert.Equal(t, found, found2, "Consistent cache lookups should return same result") assert.Equal(t, compiledSchema, compiledSchema2, "Consistent cache lookups should return same schema") } + +// TestExtractLocalsFromRawYAML_Basic tests basic locals extraction from raw YAML. +func TestExtractLocalsFromRawYAML_Basic(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +locals: + namespace: "acme" + environment: "dev" + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" +vars: + stage: "us-east-1" +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "acme", result["namespace"]) + assert.Equal(t, "dev", result["environment"]) + assert.Equal(t, "acme-dev", result["name_prefix"]) +} + +// TestExtractLocalsFromRawYAML_NoLocals tests extraction when no locals section exists. +func TestExtractLocalsFromRawYAML_NoLocals(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +vars: + stage: "us-east-1" + environment: "dev" +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + // Returns empty map when no locals are defined (safe for template processing). + assert.Empty(t, result) +} + +// TestExtractLocalsFromRawYAML_EmptyYAML tests extraction from empty YAML. +func TestExtractLocalsFromRawYAML_EmptyYAML(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := "" + + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + assert.Nil(t, result) +} + +// TestExtractLocalsFromRawYAML_InvalidYAML tests extraction from invalid YAML. +func TestExtractLocalsFromRawYAML_InvalidYAML(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +locals: + - this is not valid + namespace: "acme" + invalid yaml structure +` + _, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.Error(t, err) + assert.True(t, errors.Is(err, errUtils.ErrInvalidStackManifest), "error should wrap ErrInvalidStackManifest") + assert.Contains(t, err.Error(), "failed to parse YAML") +} + +// TestExtractLocalsFromRawYAML_TerraformSectionLocals tests extraction of terraform section locals. +func TestExtractLocalsFromRawYAML_TerraformSectionLocals(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +locals: + namespace: "acme" + environment: "dev" +terraform: + locals: + backend_bucket: "{{ .locals.namespace }}-{{ .locals.environment }}-tfstate" + backend_type: s3 +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, result) + // Global locals should be present. + assert.Equal(t, "acme", result["namespace"]) + assert.Equal(t, "dev", result["environment"]) + // Terraform section locals should be merged. + assert.Equal(t, "acme-dev-tfstate", result["backend_bucket"]) +} + +// TestExtractLocalsFromRawYAML_HelmfileSectionLocals tests extraction of helmfile section locals. +func TestExtractLocalsFromRawYAML_HelmfileSectionLocals(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +locals: + namespace: "acme" +helmfile: + locals: + release_name: "{{ .locals.namespace }}-release" +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "acme", result["namespace"]) + assert.Equal(t, "acme-release", result["release_name"]) +} + +// TestExtractLocalsFromRawYAML_PackerSectionLocals tests extraction of packer section locals. +func TestExtractLocalsFromRawYAML_PackerSectionLocals(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +locals: + namespace: "acme" +packer: + locals: + ami_name: "{{ .locals.namespace }}-ami" +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "acme", result["namespace"]) + assert.Equal(t, "acme-ami", result["ami_name"]) +} + +// TestExtractLocalsFromRawYAML_AllSectionLocals tests extraction from all sections. +func TestExtractLocalsFromRawYAML_AllSectionLocals(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +locals: + namespace: "acme" + environment: "prod" +terraform: + locals: + tf_var: "{{ .locals.namespace }}-terraform" +helmfile: + locals: + hf_var: "{{ .locals.namespace }}-helmfile" +packer: + locals: + pk_var: "{{ .locals.namespace }}-packer" +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, result) + // Global locals. + assert.Equal(t, "acme", result["namespace"]) + assert.Equal(t, "prod", result["environment"]) + // Section-specific locals. + assert.Equal(t, "acme-terraform", result["tf_var"]) + assert.Equal(t, "acme-helmfile", result["hf_var"]) + assert.Equal(t, "acme-packer", result["pk_var"]) +} + +// TestExtractLocalsFromRawYAML_CircularDependency tests circular dependency detection. +func TestExtractLocalsFromRawYAML_CircularDependency(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +locals: + a: "{{ .locals.b }}" + b: "{{ .locals.c }}" + c: "{{ .locals.a }}" +` + _, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") +} + +// TestExtractLocalsFromRawYAML_SelfReference tests self-referencing locals. +func TestExtractLocalsFromRawYAML_SelfReference(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +locals: + a: "value-a" + b: "{{ .locals.a }}-suffix" + c: "prefix-{{ .locals.b }}-suffix" +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "value-a", result["a"]) + assert.Equal(t, "value-a-suffix", result["b"]) + assert.Equal(t, "prefix-value-a-suffix-suffix", result["c"]) +} + +// TestExtractLocalsFromRawYAML_ComplexValue tests complex value types in locals. +func TestExtractLocalsFromRawYAML_ComplexValue(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +locals: + namespace: "acme" + tags: + Environment: "{{ .locals.namespace }}" + Managed: "atmos" +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "acme", result["namespace"]) + tags, ok := result["tags"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme", tags["Environment"]) + assert.Equal(t, "atmos", tags["Managed"]) +} + +// TestExtractLocalsFromRawYAML_SectionOverridesGlobal tests that section locals can override global. +func TestExtractLocalsFromRawYAML_SectionOverridesGlobal(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +locals: + namespace: "global-acme" +terraform: + locals: + namespace: "terraform-acme" +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, result) + // Terraform section should override global. + assert.Equal(t, "terraform-acme", result["namespace"]) +} + +// TestExtractLocalsFromRawYAML_TemplateInNonLocalSection tests that templates outside locals remain unresolved. +func TestExtractLocalsFromRawYAML_TemplateInNonLocalSection(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + // This test verifies that extractLocalsFromRawYAML only resolves locals, + // not templates in other sections. + yamlContent := ` +locals: + namespace: "acme" +vars: + name: "{{ .locals.namespace }}-app" +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, result) + // Only locals should be resolved and returned. + assert.Equal(t, "acme", result["namespace"]) + // vars section is not part of the locals result. + assert.Nil(t, result["name"]) +} + +// TestExtractLocalsFromRawYAML_NilAtmosConfig tests extraction with nil atmosConfig. +func TestExtractLocalsFromRawYAML_NilAtmosConfig(t *testing.T) { + yamlContent := ` +locals: + namespace: "acme" +` + result, err := extractLocalsFromRawYAML(nil, yamlContent, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "acme", result["namespace"]) +} + +// TestExtractLocalsFromRawYAML_OnlyComments tests extraction from YAML with only comments. +func TestExtractLocalsFromRawYAML_OnlyComments(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +# This is a comment +# Another comment +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + assert.Nil(t, result) +} + +// TestExtractLocalsFromRawYAML_EmptyLocals tests extraction with empty locals section. +func TestExtractLocalsFromRawYAML_EmptyLocals(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + yamlContent := ` +locals: {} +vars: + stage: "dev" +` + result, err := extractLocalsFromRawYAML(atmosConfig, yamlContent, "test.yaml") + + require.NoError(t, err) + // Empty locals should return an empty map, not nil. + require.NotNil(t, result) + assert.Empty(t, result) +} diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 32833dddf6..30393d9bc1 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -931,6 +931,7 @@ type BaseComponentConfig struct { BaseComponentEnv AtmosSectionMapType BaseComponentAuth AtmosSectionMapType BaseComponentDependencies AtmosSectionMapType + BaseComponentLocals AtmosSectionMapType // Component-level locals for template processing. BaseComponentMetadata AtmosSectionMapType BaseComponentProviders AtmosSectionMapType BaseComponentHooks AtmosSectionMapType diff --git a/tests/cli_locals_test.go b/tests/cli_locals_test.go new file mode 100644 index 0000000000..f2af5e344c --- /dev/null +++ b/tests/cli_locals_test.go @@ -0,0 +1,906 @@ +package tests + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/internal/exec" + "github.com/cloudposse/atmos/pkg/config" + "github.com/cloudposse/atmos/pkg/schema" +) + +// TestLocalsResolutionDev tests that file-scoped locals are properly resolved in dev environment. +// This is an integration test for GitHub issue #1933. +func TestLocalsResolutionDev(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals") + + _, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + require.NoError(t, err) + + // Get component configuration with locals resolved. + result, err := exec.ExecuteDescribeComponent(&exec.ExecuteDescribeComponentParams{ + Component: "mock/instance-1", + Stack: "dev-us-east-1", + ProcessTemplates: true, + ProcessYamlFunctions: true, + }) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify locals were resolved correctly in vars. + vars, ok := result["vars"].(map[string]any) + require.True(t, ok, "vars should be a map") + + // Check that {{ .locals.name_prefix }}-mock-instance-1 resolved to "acme-dev-mock-instance-1". + assert.Equal(t, "acme-dev-mock-instance-1", vars["app_name"], "app_name should be resolved from locals") + + // Check that {{ .locals.environment }} resolved to "dev". + assert.Equal(t, "dev", vars["bar"], "bar should be resolved from locals.environment") + + // Check that {{ .locals.backend_bucket }} resolved to "acme-dev-tfstate". + assert.Equal(t, "acme-dev-tfstate", vars["bucket"], "bucket should be resolved from locals.backend_bucket") + + // Verify backend was also resolved. + backend, ok := result["backend"].(map[string]any) + require.True(t, ok, "backend should be a map") + assert.Equal(t, "acme-dev-tfstate", backend["bucket"], "backend bucket should be resolved from locals") +} + +// TestLocalsResolutionProd tests locals resolution in the prod environment. +func TestLocalsResolutionProd(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals") + + _, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + require.NoError(t, err) + + // Get component configuration with locals resolved. + result, err := exec.ExecuteDescribeComponent(&exec.ExecuteDescribeComponentParams{ + Component: "mock/primary", + Stack: "prod-us-east-1", + ProcessTemplates: true, + ProcessYamlFunctions: true, + }) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify locals were resolved correctly in vars. + vars, ok := result["vars"].(map[string]any) + require.True(t, ok, "vars should be a map") + + // Check that {{ .locals.name_prefix }}-mock-primary resolved to "acme-prod-mock-primary". + assert.Equal(t, "acme-prod-mock-primary", vars["app_name"], "app_name should be resolved from locals") + + // Check that {{ .locals.environment }} resolved to "prod". + assert.Equal(t, "prod", vars["bar"], "bar should be resolved from locals.environment") + + // Check that {{ .locals.backend_bucket }} resolved to "acme-prod-tfstate". + assert.Equal(t, "acme-prod-tfstate", vars["bucket"], "bucket should be resolved from locals.backend_bucket") +} + +// TestLocalsDescribeStacks tests that describe stacks works with locals. +func TestLocalsDescribeStacks(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get all stacks configuration. + result, err := exec.ExecuteDescribeStacks( + &atmosConfig, + "", // filterByStack + nil, // components + nil, // componentTypes + nil, // sections + true, // ignoreMissingFiles + true, // processTemplates + true, // processYamlFunctions + false, // includeEmptyStacks + nil, // skip + nil, // authManager + ) + require.NoError(t, err) + require.NotNil(t, result) + require.NotEmpty(t, result, "should have at least one stack") + + // Find a stack that contains the mock/instance-1 component. + var foundStack map[string]any + for _, stackData := range result { + stack, ok := stackData.(map[string]any) + if !ok { + continue + } + components, ok := stack["components"].(map[string]any) + if !ok { + continue + } + terraform, ok := components["terraform"].(map[string]any) + if !ok { + continue + } + if _, exists := terraform["mock/instance-1"]; exists { + foundStack = stack + break + } + } + require.NotNil(t, foundStack, "should find a stack with mock/instance-1 component") + + components, ok := foundStack["components"].(map[string]any) + require.True(t, ok, "components should be a map") + terraform, ok := components["terraform"].(map[string]any) + require.True(t, ok, "terraform section should be a map") + mockInstance1, ok := terraform["mock/instance-1"].(map[string]any) + require.True(t, ok, "mock/instance-1 should be a map") + vars, ok := mockInstance1["vars"].(map[string]any) + require.True(t, ok, "vars should be a map") + + // Verify locals were resolved. + assert.Equal(t, "acme-dev-mock-instance-1", vars["app_name"], "app_name should be resolved") + assert.Equal(t, "dev", vars["bar"], "bar should be resolved") +} + +// TestLocalsCircularDependency verifies that circular locals produce a clear error. +// Circular dependencies in locals are a stack misconfiguration and should fail with a helpful error. +func TestLocalsCircularDependency(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-circular") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get all stacks - should fail due to circular locals. + _, err = exec.ExecuteDescribeStacks( + &atmosConfig, + "", // filterByStack + nil, // components + nil, // componentTypes + nil, // sections + true, // ignoreMissingFiles + true, // processTemplates + true, // processYamlFunctions + false, // includeEmptyStacks + nil, // skip + nil, // authManager + ) + + // Should error - circular locals are a stack misconfiguration. + require.Error(t, err, "circular dependency in locals should produce an error") + assert.ErrorIs(t, err, errUtils.ErrLocalsCircularDep, "error should be ErrLocalsCircularDep") + assert.Contains(t, err.Error(), "circular dependency", "error message should mention circular dependency") +} + +// TestLocalsFileScoped verifies that locals are file-scoped and NOT inherited across imports. +// This is a critical test for the file-scoped locals design. +// - Locals defined in a mixin file should NOT be available in files that import it. +// - Only the file's own locals should be resolvable. +// - Regular vars ARE inherited (normal Atmos behavior). +func TestLocalsFileScoped(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-file-scoped") + + _, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + require.NoError(t, err) + + // Get component configuration. + result, err := exec.ExecuteDescribeComponent(&exec.ExecuteDescribeComponentParams{ + Component: "test-component", + Stack: "test", + ProcessTemplates: true, + ProcessYamlFunctions: true, + }) + require.NoError(t, err) + require.NotNil(t, result) + + vars, ok := result["vars"].(map[string]any) + require.True(t, ok, "vars should be a map") + + // File's own locals SHOULD resolve. + // {{ .locals.file_computed }} should resolve to "file-ns-file-env". + assert.Equal(t, "file-ns-file-env", vars["own_local"], + "file's own locals should be resolved") + + // Verify that the mixin's locals are NOT inherited by checking the component vars. + // The mixin defines locals (mixin_namespace, mixin_env, mixin_computed) but these + // should NOT be available in the importing file - only the file's own locals work. + // Since we don't reference mixin locals in the template (it would cause an error), + // we verify by confirming our own locals work while the mixin defined different ones. +} + +// TestLocalsNotInherited verifies that mixin locals are NOT inherited by importing files. +// This proves that locals are file-scoped and not inherited across file boundaries. +// When a file tries to use {{ .locals.mixin_value }} but mixin_value is defined in an +// imported file, the local is not available and resolves to "". +func TestLocalsNotInherited(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-not-inherited") + + _, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + require.NoError(t, err) + + // Get component configuration. + result, err := exec.ExecuteDescribeComponent(&exec.ExecuteDescribeComponentParams{ + Component: "test-component", + Stack: "test", + ProcessTemplates: true, + ProcessYamlFunctions: true, + }) + require.NoError(t, err) + require.NotNil(t, result) + + vars, ok := result["vars"].(map[string]any) + require.True(t, ok, "vars should be a map") + + // The mixin's local should NOT be available. + // {{ .locals.mixin_value }} should resolve to "" (not "from-mixin-locals"). + attemptMixinLocal, ok := vars["attempt_mixin_local"].(string) + require.True(t, ok, "attempt_mixin_local should be a string") + assert.NotEqual(t, "from-mixin-locals", attemptMixinLocal, + "mixin locals should NOT be inherited - locals are file-scoped") + assert.Equal(t, "", attemptMixinLocal, + "unresolved mixin local should be ''") + + // However, regular vars from the mixin ARE inherited (normal Atmos behavior). + inheritedVar, ok := vars["inherited_var"].(string) + require.True(t, ok, "inherited_var should be a string") + assert.Equal(t, "from-mixin-vars", inheritedVar, + "regular vars from mixin should be inherited") +} + +// TestLocalsNotInFinalOutput verifies that locals sections are stripped from the final component output. +// Locals are only used during template processing and should not appear in describe output. +func TestLocalsNotInFinalOutput(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals") + + _, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + require.NoError(t, err) + + // Get component configuration. + result, err := exec.ExecuteDescribeComponent(&exec.ExecuteDescribeComponentParams{ + Component: "mock/instance-1", + Stack: "dev-us-east-1", + ProcessTemplates: true, + ProcessYamlFunctions: true, + }) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify that the locals section is NOT present in the output. + // Locals are internal to template processing and should be stripped. + _, hasLocals := result["locals"] + assert.False(t, hasLocals, "locals section should NOT appear in component output") +} + +// TestDescribeLocals verifies that the describe locals command correctly extracts +// locals from a stack file and presents them in direct Atmos schema format. +func TestDescribeLocals(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get locals for the dev stack (--stack is required). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "deploy/dev") + require.NoError(t, err) + require.NotNil(t, result) + require.NotEmpty(t, result, "should have locals") + + // Result is now in direct format (no stack name wrapper). + // Check root-level locals (Atmos schema format). + locals, ok := result["locals"].(map[string]any) + require.True(t, ok, "should have locals section") + assert.Equal(t, "dev", locals["environment"], "environment should be 'dev'") + assert.Equal(t, "acme", locals["namespace"], "namespace should be 'acme'") + assert.Equal(t, "acme-dev", locals["name_prefix"], "name_prefix should be 'acme-dev'") + + // Check terraform section locals (Atmos schema format: terraform.locals). + terraform, ok := result["terraform"].(map[string]any) + require.True(t, ok, "should have terraform section") + tfLocals, ok := terraform["locals"].(map[string]any) + require.True(t, ok, "should have terraform.locals section") + assert.Equal(t, "acme-dev-tfstate", tfLocals["backend_bucket"], + "terraform.locals should include backend_bucket") +} + +// TestDescribeLocalsWithFilter verifies that the describe locals command +// correctly returns locals for a specific stack. +func TestDescribeLocalsWithFilter(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get locals for prod stack (direct format). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "deploy/prod") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + // Check root-level locals (Atmos schema format). + locals, ok := result["locals"].(map[string]any) + require.True(t, ok, "should have locals section") + assert.Equal(t, "prod", locals["environment"], "environment should be 'prod'") +} + +// TestLocalsDeepImportChain verifies that file-scoped locals work correctly +// through a deep import chain (base -> layer1 -> layer2 -> final). +// This tests that locals are NOT inherited through multiple levels of imports. +func TestLocalsDeepImportChain(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-deep-import-chain") + + _, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + require.NoError(t, err) + + // Get component configuration with locals resolved. + result, err := exec.ExecuteDescribeComponent(&exec.ExecuteDescribeComponentParams{ + Component: "deep-chain-component", + Stack: "final", + ProcessTemplates: true, + ProcessYamlFunctions: true, + }) + require.NoError(t, err) + require.NotNil(t, result) + + vars, ok := result["vars"].(map[string]any) + require.True(t, ok, "vars should be a map") + + // File's own locals SHOULD resolve correctly. + assert.Equal(t, "from-final-stack", vars["local_value"], + "{{ .locals.final_local }} should resolve to the file's own local") + assert.Equal(t, "from-final-stack-computed", vars["computed"], + "{{ .locals.computed_value }} should resolve correctly (locals referencing locals)") + assert.Equal(t, "final-value", vars["shared"], + "{{ .locals.shared_key }} should resolve to the file's own value, not parent") + assert.Equal(t, "final-value-from-final-stack", vars["full_chain"], + "nested local references should resolve correctly") + + // Verify that regular vars ARE inherited through the chain. + // Unlike locals, vars follow normal Atmos inheritance. + assert.Equal(t, "from-base-vars", vars["base_var"], + "vars from base mixin should be inherited") + assert.Equal(t, "from-layer1-vars", vars["layer1_var"], + "vars from layer1 mixin should be inherited") + assert.Equal(t, "from-layer2-vars", vars["layer2_var"], + "vars from layer2 mixin should be inherited") + assert.Equal(t, "from-final-vars", vars["final_var"], + "vars from final stack should be present") +} + +// TestLocalsDeepImportChainDescribeStacks tests that describe stacks works +// correctly with a deep import chain and file-scoped locals. +func TestLocalsDeepImportChainDescribeStacks(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-deep-import-chain") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get all stacks. + result, err := exec.ExecuteDescribeStacks( + &atmosConfig, + "", // filterByStack + nil, // components + nil, // componentTypes + nil, // sections + true, // ignoreMissingFiles + true, // processTemplates + true, // processYamlFunctions + false, // includeEmptyStacks + nil, // skip + nil, // authManager + ) + require.NoError(t, err) + require.NotNil(t, result) + require.NotEmpty(t, result, "should have at least one stack") + + // Find the final stack. + finalStack, ok := result["final"].(map[string]any) + require.True(t, ok, "should find the 'final' stack") + + components, ok := finalStack["components"].(map[string]any) + require.True(t, ok, "components should be a map") + terraform, ok := components["terraform"].(map[string]any) + require.True(t, ok, "terraform section should be a map") + component, ok := terraform["deep-chain-component"].(map[string]any) + require.True(t, ok, "deep-chain-component should exist") + vars, ok := component["vars"].(map[string]any) + require.True(t, ok, "vars should be a map") + + // Verify locals were resolved correctly. + assert.Equal(t, "from-final-stack", vars["local_value"], + "locals should be resolved in describe stacks output") + assert.Equal(t, "from-final-stack-computed", vars["computed"], + "computed locals should be resolved") +} + +// TestLocalsDescribeLocalsDeepChain tests that describe locals command +// shows each file's locals independently in a deep import chain. +func TestLocalsDescribeLocalsDeepChain(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-deep-import-chain") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get locals for the final stack (--stack is required). + // The fixture has name_template: "{{ .vars.stage }}" and vars.stage: "final", + // so the derived stack name is "final" (not "deploy/final"). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "final") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + // Check root-level locals (Atmos schema format). + locals, ok := result["locals"].(map[string]any) + require.True(t, ok, "should have locals section") + assert.Equal(t, "from-final-stack", locals["final_local"], + "final_local should be from this file") + assert.Equal(t, "final-value", locals["shared_key"], + "shared_key should be from this file, not inherited") + + // The mixin files define locals but those should NOT appear here. + // Each file's locals are independent. + _, hasBaseLocal := locals["base_local"] + assert.False(t, hasBaseLocal, "base_local should NOT be present - it's in base mixin") + _, hasLayer1Local := locals["layer1_local"] + assert.False(t, hasLayer1Local, "layer1_local should NOT be present - it's in layer1 mixin") + _, hasLayer2Local := locals["layer2_local"] + assert.False(t, hasLayer2Local, "layer2_local should NOT be present - it's in layer2 mixin") +} + +// TestDescribeLocalsForComponent tests that describe locals command correctly +// returns locals for a specific stack. +// This tests the `atmos describe locals -s ` functionality. +func TestDescribeLocalsForComponent(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Test getting locals for a stack. + t.Run("returns locals for terraform component", func(t *testing.T) { + // Get locals for deploy/dev stack (direct format). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "deploy/dev") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + // Check root-level locals. + locals, ok := result["locals"].(map[string]any) + require.True(t, ok, "should have locals section") + assert.Equal(t, "acme", locals["namespace"], "should have namespace") + assert.Equal(t, "dev", locals["environment"], "should have environment") + + // Check terraform section has section-specific locals. + terraform, ok := result["terraform"].(map[string]any) + require.True(t, ok, "should have terraform section") + tfLocals, ok := terraform["locals"].(map[string]any) + require.True(t, ok, "should have terraform.locals section") + + // Verify terraform-specific locals (only section-specific, not merged). + assert.Equal(t, "acme-dev-tfstate", tfLocals["backend_bucket"], + "terraform.locals should include backend_bucket") + assert.Equal(t, "terraform-only", tfLocals["tf_specific"], + "terraform.locals should include tf_specific") + }) +} + +// TestDescribeLocalsForComponentOutput tests the full output structure +// when describing locals for a specific stack (direct Atmos schema format). +func TestDescribeLocalsForComponentOutput(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get locals for stack (direct format). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "deploy/dev") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + // Check root-level locals (Atmos schema format). + locals, ok := result["locals"].(map[string]any) + require.True(t, ok, "should have locals section") + + // Root locals should have global locals. + assert.Equal(t, "acme", locals["namespace"]) + assert.Equal(t, "dev", locals["environment"]) + assert.Equal(t, "us-east-1", locals["stage"]) + assert.Equal(t, "acme-dev", locals["name_prefix"]) + assert.Equal(t, "acme-dev-us-east-1", locals["full_name"]) + + // Check terraform section has terraform-specific locals. + terraform, ok := result["terraform"].(map[string]any) + require.True(t, ok, "should have terraform section") + tfLocals, ok := terraform["locals"].(map[string]any) + require.True(t, ok, "should have terraform.locals section") + assert.Equal(t, "acme-dev-tfstate", tfLocals["backend_bucket"]) + assert.Equal(t, "terraform-only", tfLocals["tf_specific"]) +} + +// TestDescribeLocalsForComponentInProdStack tests locals for the prod stack +// to ensure different stacks have independent locals. +func TestDescribeLocalsForComponentInProdStack(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get locals for prod stack (direct format). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "deploy/prod") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + // Check root-level locals (Atmos schema format). + locals, ok := result["locals"].(map[string]any) + require.True(t, ok, "should have locals section") + + // Verify prod-specific values. + assert.Equal(t, "acme", locals["namespace"]) + assert.Equal(t, "prod", locals["environment"]) + + // Check terraform section has terraform-specific locals. + terraform, ok := result["terraform"].(map[string]any) + require.True(t, ok, "should have terraform section") + tfLocals, ok := terraform["locals"].(map[string]any) + require.True(t, ok, "should have terraform.locals section") + assert.Equal(t, "acme-prod-tfstate", tfLocals["backend_bucket"], + "prod should have prod-specific backend_bucket") +} + +// ============================================================================= +// Logical Stack Name Tests +// ============================================================================= +// These tests use the locals-logical-names fixture where vars contain literal +// values (not templates), allowing name_template to derive logical stack names. + +// TestDescribeLocalsWithLogicalStackName tests that ExecuteDescribeLocals +// correctly resolves logical stack names when configured. +func TestDescribeLocalsWithLogicalStackName(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-logical-names") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // The fixture has name_template: "{{ .vars.environment }}-{{ .vars.stage }}" + // dev.yaml has vars: {environment: dev, stage: us-east-1} -> "dev-us-east-1" + + // Get locals for dev stack using logical name (direct format). + devResult, err := exec.ExecuteDescribeLocals(&atmosConfig, "dev-us-east-1") + require.NoError(t, err) + require.NotNil(t, devResult) + + // Verify locals content for dev stack (direct format). + locals, ok := devResult["locals"].(map[string]any) + require.True(t, ok, "dev stack should have locals section") + assert.Equal(t, "acme", locals["namespace"]) + assert.Equal(t, "acme-dev", locals["env_prefix"]) + + // Get locals for prod stack using logical name (direct format). + prodResult, err := exec.ExecuteDescribeLocals(&atmosConfig, "prod-us-west-2") + require.NoError(t, err) + require.NotNil(t, prodResult) + + // Verify locals content for prod stack (direct format). + prodLocals, ok := prodResult["locals"].(map[string]any) + require.True(t, ok, "prod stack should have locals section") + assert.Equal(t, "acme", prodLocals["namespace"]) + assert.Equal(t, "acme-prod", prodLocals["env_prefix"]) +} + +// TestDescribeLocalsFilterByLogicalStackName tests filtering by logical stack name. +func TestDescribeLocalsFilterByLogicalStackName(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-logical-names") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get locals using logical stack name "dev-us-east-1" (direct format). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "dev-us-east-1") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + locals, ok := result["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme", locals["namespace"]) +} + +// TestDescribeLocalsFilterByFilePath tests filtering by file path when logical names are available. +func TestDescribeLocalsFilterByFilePath(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-logical-names") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get locals using file path "deploy/prod" (direct format). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "deploy/prod") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + locals, ok := result["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme-prod", locals["env_prefix"]) +} + +// TestDescribeLocalsOutputStructureStack tests the output structure when querying stacks (no component). +// Output follows direct Atmos schema format: locals:, terraform: {locals:}, helmfile: {locals:}, etc. +func TestDescribeLocalsOutputStructureStack(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-logical-names") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "prod-us-west-2") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + // Verify output structure has Atmos schema format. + _, hasLocals := result["locals"] + _, hasTerraform := result["terraform"] + _, hasHelmfile := result["helmfile"] + + assert.True(t, hasLocals, "stack output should have 'locals' section (root-level locals)") + assert.True(t, hasTerraform, "stack output should have 'terraform' section") + assert.True(t, hasHelmfile, "stack output should have 'helmfile' section (prod has helmfile locals)") + + // Verify root-level locals. + locals, ok := result["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme", locals["namespace"]) + + // Verify terraform section has nested locals (terraform.locals). + terraform, ok := result["terraform"].(map[string]any) + require.True(t, ok) + tfLocals, ok := terraform["locals"].(map[string]any) + require.True(t, ok, "terraform section should have nested locals") + assert.Equal(t, "acme-prod-tfstate", tfLocals["backend_bucket"]) + assert.Equal(t, "terraform-specific-prod", tfLocals["tf_only"]) + + // Verify helmfile section has nested locals (helmfile.locals). + helmfile, ok := result["helmfile"].(map[string]any) + require.True(t, ok) + hfLocals, ok := helmfile["locals"].(map[string]any) + require.True(t, ok, "helmfile section should have nested locals") + assert.Equal(t, "acme-prod-release", hfLocals["release_name"]) + assert.Equal(t, "helmfile-specific-prod", hfLocals["hf_only"]) +} + +// TestDescribeLocalsOutputStructureComponent tests the output structure when querying for a stack. +func TestDescribeLocalsOutputStructureComponent(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-logical-names") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get locals for the stack (direct format). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "dev-us-east-1") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + // Verify root-level locals. + locals, ok := result["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme", locals["namespace"], "root locals should include namespace") + + // Verify terraform section locals (Atmos schema: terraform.locals). + terraform, ok := result["terraform"].(map[string]any) + require.True(t, ok) + tfLocals, ok := terraform["locals"].(map[string]any) + require.True(t, ok, "terraform section should have nested locals") + assert.Equal(t, "acme-dev-tfstate", tfLocals["backend_bucket"]) + assert.Equal(t, "terraform-specific-dev", tfLocals["tf_only"]) +} + +// TestDescribeLocalsComponentWithLogicalStackName tests component argument with logical stack name. +func TestDescribeLocalsComponentWithLogicalStackName(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-logical-names") + + _, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Verify component resolution works with logical stack name. + // The component "vpc" in stack "dev-us-east-1" should return terraform locals. + result, err := exec.ExecuteDescribeComponent(&exec.ExecuteDescribeComponentParams{ + Component: "vpc", + Stack: "dev-us-east-1", + ProcessTemplates: true, + ProcessYamlFunctions: true, + }) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify vars were resolved from locals. + vars, ok := result["vars"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme-dev-us-east-1-vpc", vars["name"]) + assert.Equal(t, "acme-dev-tfstate", vars["bucket"]) +} + +// TestDescribeLocalsComponentWithFilePath tests component argument with file path via ExecuteDescribeLocals. +// Note: ExecuteDescribeComponent uses different stack resolution logic and may not work with file paths +// when a global config overrides the fixture config. This test verifies ExecuteDescribeLocals accepts file paths. +func TestDescribeLocalsComponentWithFilePath(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-logical-names") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Verify ExecuteDescribeLocals accepts file path "deploy/prod" as filter (direct format). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "deploy/prod") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + // Verify terraform section locals (Atmos schema: terraform.locals). + terraform, ok := result["terraform"].(map[string]any) + require.True(t, ok) + tfLocals, ok := terraform["locals"].(map[string]any) + require.True(t, ok, "terraform section should have nested locals") + assert.Equal(t, "acme-prod-tfstate", tfLocals["backend_bucket"]) +} + +// TestDescribeLocalsHelmfileComponent tests locals for helmfile component type. +func TestDescribeLocalsHelmfileComponent(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-logical-names") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Get locals for prod stack which has helmfile locals (direct format). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "prod-us-west-2") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + // Verify helmfile section locals (Atmos schema: helmfile.locals). + helmfile, ok := result["helmfile"].(map[string]any) + require.True(t, ok) + hfLocals, ok := helmfile["locals"].(map[string]any) + require.True(t, ok, "helmfile section should have nested locals") + + // Helmfile section should have helmfile-specific locals only. + assert.Equal(t, "acme-prod-release", hfLocals["release_name"]) + assert.Equal(t, "helmfile-specific-prod", hfLocals["hf_only"]) + + // Global locals are in root "locals:" section, not merged into helmfile.locals. + locals, ok := result["locals"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "acme", locals["namespace"]) + assert.Equal(t, "acme-prod", locals["env_prefix"]) +} + +// TestDescribeLocalsDifferentOutputStructures verifies that stack queries return +// direct Atmos schema format output. +func TestDescribeLocalsDifferentOutputStructures(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-logical-names") + + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // Stack query output structure (direct format). + result, err := exec.ExecuteDescribeLocals(&atmosConfig, "dev-us-east-1") + require.NoError(t, err) + require.NotNil(t, result) + + // Result is now in direct format (no stack name wrapper). + // Verify output has Atmos schema format (locals:, terraform: {locals:}, etc.). + _, hasLocals := result["locals"] + assert.True(t, hasLocals, "stack output should have 'locals' key (root-level locals)") + + // Stack output should NOT have the old format keys. + _, hasGlobal := result["global"] + _, hasMerged := result["merged"] + assert.False(t, hasGlobal, "stack output should NOT have 'global' key (old format)") + assert.False(t, hasMerged, "stack output should NOT have 'merged' key (old format)") + + // Stack output should NOT have component-specific keys. + _, hasComponent := result["component"] + _, hasComponentType := result["component_type"] + assert.False(t, hasComponent, "stack output should NOT have 'component' key") + assert.False(t, hasComponentType, "stack output should NOT have 'component_type' key") +} + +// ============================================================================= +// Component-Level Locals Tests +// ============================================================================= +// These tests verify that component-level locals work correctly, including +// inheritance from base components via metadata.inherits. +// +// Note: Component-level locals are stored and inherited, but they are NOT +// available for {{ .locals.X }} template resolution within the same file. +// Only file-level locals (global + section) are available during template +// processing. Component-level locals appear in the final component output +// and can be used by downstream tooling. + +// TestComponentLevelLocals tests component-level locals functionality using table-driven tests. +func TestComponentLevelLocals(t *testing.T) { + t.Chdir("./fixtures/scenarios/locals-component-level") + + _, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + require.NoError(t, err) + + tests := []struct { + name string + component string + expectedVars map[string]string + expectedLocals map[string]string + }{ + { + name: "standalone component with component-level locals", + component: "standalone", + expectedVars: map[string]string{ + "name": "acme-dev-standalone", + "bucket": "acme-dev-tfstate", + }, + expectedLocals: map[string]string{ + "standalone_value": "standalone-only", + "computed_ref": "acme-dev", + }, + }, + { + name: "component inheriting with locals override", + component: "vpc/dev", + expectedVars: map[string]string{ + "name": "acme-dev-vpc", + "bucket": "acme-dev-tfstate", + "description": "acme-dev-vpc-dev", + }, + expectedLocals: map[string]string{ + "cidr_prefix": "10.0", + "vpc_type": "development", + "extra_tag": "dev-only", + }, + }, + { + name: "component inheriting without locals override", + component: "vpc/standard", + expectedVars: map[string]string{ + "name": "acme-dev-vpc", + "description": "acme-dev-vpc-standard", + }, + expectedLocals: map[string]string{ + "vpc_type": "standard", + "cidr_prefix": "10.0", + }, + }, + { + name: "component with component attribute", + component: "vpc/custom", + expectedVars: map[string]string{ + "prefix": "acme-dev", + }, + expectedLocals: map[string]string{ + "custom_local": "custom-value", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := exec.ExecuteDescribeComponent(&exec.ExecuteDescribeComponentParams{ + Component: tt.component, + Stack: "dev-us-east-1", + ProcessTemplates: true, + ProcessYamlFunctions: true, + }) + require.NoError(t, err) + require.NotNil(t, result) + + // Verify vars. + vars, ok := result["vars"].(map[string]any) + require.True(t, ok, "vars should be a map") + for key, expected := range tt.expectedVars { + assert.Equal(t, expected, vars[key], "vars[%s] mismatch", key) + } + + // Verify locals. + locals, hasLocals := result["locals"].(map[string]any) + require.True(t, hasLocals, "component should have locals in output") + for key, expected := range tt.expectedLocals { + assert.Equal(t, expected, locals[key], "locals[%s] mismatch", key) + } + }) + } +} diff --git a/tests/fixtures/scenarios/locals-component-level/atmos.yaml b/tests/fixtures/scenarios/locals-component-level/atmos.yaml new file mode 100644 index 0000000000..4bcbd3f038 --- /dev/null +++ b/tests/fixtures/scenarios/locals-component-level/atmos.yaml @@ -0,0 +1,33 @@ +base_path: "./" + +components: + terraform: + base_path: "../../components/terraform" + apply_auto_approve: false + deploy_run_init: true + init_run_reconfigure: true + auto_generate_backend_file: true + +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/_defaults.yaml" + name_template: "{{ .vars.environment }}-{{ .vars.stage }}" + +logs: + file: "/dev/stderr" + level: Info + +# Enable Go templates for locals resolution. +templates: + settings: + enabled: true + evaluations: 1 + sprig: + enabled: true + gomplate: + enabled: true + timeout: 10 + datasources: {} diff --git a/tests/fixtures/scenarios/locals-component-level/stacks/deploy/dev.yaml b/tests/fixtures/scenarios/locals-component-level/stacks/deploy/dev.yaml new file mode 100644 index 0000000000..1d6945fb68 --- /dev/null +++ b/tests/fixtures/scenarios/locals-component-level/stacks/deploy/dev.yaml @@ -0,0 +1,75 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +# Global locals - available to all sections in this file. +locals: + namespace: "acme" + environment: "dev" + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" + +vars: + environment: "dev" + stage: "us-east-1" + +terraform: + # Terraform-scope locals - inherit from global, can override. + locals: + backend_bucket: "{{ .locals.namespace }}-{{ .locals.environment }}-tfstate" + tf_specific: "terraform-only" + +components: + terraform: + # Base component with component-level locals. + vpc/base: + metadata: + type: abstract + component: mock + locals: + vpc_type: "standard" + cidr_prefix: "10.0" + vars: + # Use file-level locals (global + terraform section). + name: "{{ .locals.name_prefix }}-vpc" + bucket: "{{ .locals.backend_bucket }}" + + # Component inheriting from vpc/base with locals override. + vpc/dev: + metadata: + inherits: + - vpc/base + locals: + # Override vpc_type from base. + vpc_type: "development" + # Add new local. + extra_tag: "dev-only" + vars: + # Use file-level locals. + description: "{{ .locals.name_prefix }}-vpc-dev" + + # Component inheriting from vpc/base without locals override. + vpc/standard: + metadata: + inherits: + - vpc/base + vars: + # Use file-level locals. + description: "{{ .locals.name_prefix }}-vpc-standard" + + # Component with only component-level locals (no inheritance). + standalone: + metadata: + component: mock + locals: + standalone_value: "standalone-only" + computed_ref: "{{ .locals.name_prefix }}" + vars: + # Use file-level locals. + name: "{{ .locals.name_prefix }}-standalone" + bucket: "{{ .locals.backend_bucket }}" + + # Component with component attribute (different from metadata.inherits). + vpc/custom: + component: mock + locals: + custom_local: "custom-value" + vars: + prefix: "{{ .locals.name_prefix }}" diff --git a/tests/fixtures/scenarios/locals-deep-import-chain/atmos.yaml b/tests/fixtures/scenarios/locals-deep-import-chain/atmos.yaml new file mode 100644 index 0000000000..bcfdf881a5 --- /dev/null +++ b/tests/fixtures/scenarios/locals-deep-import-chain/atmos.yaml @@ -0,0 +1,28 @@ +base_path: "./" + +components: + terraform: + base_path: "../../components/terraform" + +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/mixins/**" + name_template: "{{ .vars.stage }}" + +logs: + file: "/dev/stderr" + level: Info + +templates: + settings: + enabled: true + evaluations: 1 + sprig: + enabled: true + gomplate: + enabled: true + timeout: 10 + datasources: {} diff --git a/tests/fixtures/scenarios/locals-deep-import-chain/stacks/deploy/final.yaml b/tests/fixtures/scenarios/locals-deep-import-chain/stacks/deploy/final.yaml new file mode 100644 index 0000000000..93576abc4a --- /dev/null +++ b/tests/fixtures/scenarios/locals-deep-import-chain/stacks/deploy/final.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +import: + - mixins/layer2 + +# Final stack file - can only access its own locals. +locals: + final_local: "from-final-stack" + shared_key: "final-value" + # Use our own locals in templates (tests dependency resolution). + computed_value: "{{ .locals.final_local }}-computed" + full_chain: "{{ .locals.shared_key }}-{{ .locals.final_local }}" + +vars: + stage: "final" + final_var: "from-final-vars" + +components: + terraform: + deep-chain-component: + metadata: + component: mock + vars: + # These should resolve using THIS file's locals only. + local_value: "{{ .locals.final_local }}" + computed: "{{ .locals.computed_value }}" + shared: "{{ .locals.shared_key }}" + full_chain: "{{ .locals.full_chain }}" diff --git a/tests/fixtures/scenarios/locals-deep-import-chain/stacks/mixins/base.yaml b/tests/fixtures/scenarios/locals-deep-import-chain/stacks/mixins/base.yaml new file mode 100644 index 0000000000..c00b56183b --- /dev/null +++ b/tests/fixtures/scenarios/locals-deep-import-chain/stacks/mixins/base.yaml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +# Level 1: Base mixin - defines locals that should NOT be inherited. +locals: + base_local: "from-base-mixin" + shared_key: "base-value" + +# Regular vars ARE inherited through the chain. +vars: + base_var: "from-base-vars" diff --git a/tests/fixtures/scenarios/locals-deep-import-chain/stacks/mixins/layer1.yaml b/tests/fixtures/scenarios/locals-deep-import-chain/stacks/mixins/layer1.yaml new file mode 100644 index 0000000000..87da786fe5 --- /dev/null +++ b/tests/fixtures/scenarios/locals-deep-import-chain/stacks/mixins/layer1.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +import: + - mixins/base + +# Level 2: Layer1 mixin - has its own locals (cannot see base locals). +# Note: base_local from mixins/base is NOT accessible here (file-scoped). +locals: + layer1_local: "from-layer1-mixin" + shared_key: "layer1-value" + +# Regular vars ARE inherited. +vars: + layer1_var: "from-layer1-vars" diff --git a/tests/fixtures/scenarios/locals-deep-import-chain/stacks/mixins/layer2.yaml b/tests/fixtures/scenarios/locals-deep-import-chain/stacks/mixins/layer2.yaml new file mode 100644 index 0000000000..5ddb1af24b --- /dev/null +++ b/tests/fixtures/scenarios/locals-deep-import-chain/stacks/mixins/layer2.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +import: + - mixins/layer1 + +# Level 3: Layer2 mixin - has its own locals (cannot see base or layer1 locals). +locals: + layer2_local: "from-layer2-mixin" + shared_key: "layer2-value" + +# Regular vars ARE inherited from all levels. +vars: + layer2_var: "from-layer2-vars" diff --git a/tests/fixtures/scenarios/locals-file-scoped/atmos.yaml b/tests/fixtures/scenarios/locals-file-scoped/atmos.yaml new file mode 100644 index 0000000000..4ca4cf1b32 --- /dev/null +++ b/tests/fixtures/scenarios/locals-file-scoped/atmos.yaml @@ -0,0 +1,33 @@ +base_path: "./" + +components: + terraform: + base_path: "../../components/terraform" + apply_auto_approve: false + deploy_run_init: true + init_run_reconfigure: true + auto_generate_backend_file: true + +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/_defaults.yaml" + - "**/mixins/**" + name_template: "{{ .vars.stage }}" + +logs: + file: "/dev/stderr" + level: Info + +templates: + settings: + enabled: true + evaluations: 1 + sprig: + enabled: true + gomplate: + enabled: true + timeout: 10 + datasources: {} diff --git a/tests/fixtures/scenarios/locals-file-scoped/stacks/deploy/test.yaml b/tests/fixtures/scenarios/locals-file-scoped/stacks/deploy/test.yaml new file mode 100644 index 0000000000..5207a0f20b --- /dev/null +++ b/tests/fixtures/scenarios/locals-file-scoped/stacks/deploy/test.yaml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +import: + - mixins/base + +# This file's own locals - these SHOULD be available. +locals: + file_namespace: "file-ns" + file_env: "file-env" + file_computed: "{{ .locals.file_namespace }}-{{ .locals.file_env }}" + +vars: + stage: "test" + +components: + terraform: + test-component: + metadata: + component: mock + vars: + # This should resolve to the file's own local. + own_local: "{{ .locals.file_computed }}" diff --git a/tests/fixtures/scenarios/locals-file-scoped/stacks/mixins/base.yaml b/tests/fixtures/scenarios/locals-file-scoped/stacks/mixins/base.yaml new file mode 100644 index 0000000000..95355ff24a --- /dev/null +++ b/tests/fixtures/scenarios/locals-file-scoped/stacks/mixins/base.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +# This mixin defines locals that should NOT be inherited by files that import it. +# Locals are file-scoped and should not cross file boundaries. +locals: + mixin_namespace: "mixin-ns" + mixin_env: "mixin-env" + mixin_computed: "{{ .locals.mixin_namespace }}-{{ .locals.mixin_env }}" + +# These vars WILL be inherited (normal Atmos behavior). +vars: + inherited_var: "from-mixin" diff --git a/tests/fixtures/scenarios/locals-logical-names/atmos.yaml b/tests/fixtures/scenarios/locals-logical-names/atmos.yaml new file mode 100644 index 0000000000..9baa241464 --- /dev/null +++ b/tests/fixtures/scenarios/locals-logical-names/atmos.yaml @@ -0,0 +1,33 @@ +base_path: "./" + +components: + terraform: + base_path: "components/terraform" + apply_auto_approve: false + deploy_run_init: true + init_run_reconfigure: true + auto_generate_backend_file: true + +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/_defaults.yaml" + # This name_template will derive logical stack names from literal vars. + name_template: "{{ .vars.environment }}-{{ .vars.stage }}" + +logs: + file: "/dev/stderr" + level: Info + +templates: + settings: + enabled: true + evaluations: 1 + sprig: + enabled: true + gomplate: + enabled: true + timeout: 10 + datasources: {} diff --git a/tests/fixtures/scenarios/locals-logical-names/components/terraform/mock/main.tf b/tests/fixtures/scenarios/locals-logical-names/components/terraform/mock/main.tf new file mode 100644 index 0000000000..9db233b8c8 --- /dev/null +++ b/tests/fixtures/scenarios/locals-logical-names/components/terraform/mock/main.tf @@ -0,0 +1,14 @@ +variable "name" { + type = string + default = "" +} + +variable "bucket" { + type = string + default = "" +} + +variable "release" { + type = string + default = "" +} diff --git a/tests/fixtures/scenarios/locals-logical-names/stacks/deploy/dev.yaml b/tests/fixtures/scenarios/locals-logical-names/stacks/deploy/dev.yaml new file mode 100644 index 0000000000..47fced1e42 --- /dev/null +++ b/tests/fixtures/scenarios/locals-logical-names/stacks/deploy/dev.yaml @@ -0,0 +1,34 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +# Vars with LITERAL values (not templates) so name_template can derive logical stack name. +# This results in logical stack name: "dev-us-east-1" (from name_template). +vars: + environment: dev + stage: us-east-1 + +# File-scoped locals. +locals: + namespace: acme + env_prefix: "{{ .locals.namespace }}-dev" + full_prefix: "{{ .locals.env_prefix }}-us-east-1" + +terraform: + locals: + backend_bucket: "{{ .locals.namespace }}-dev-tfstate" + tf_only: terraform-specific-dev + +components: + terraform: + vpc: + metadata: + component: mock + vars: + name: "{{ .locals.full_prefix }}-vpc" + bucket: "{{ .locals.backend_bucket }}" + + rds: + metadata: + component: mock + vars: + name: "{{ .locals.full_prefix }}-rds" + bucket: "{{ .locals.backend_bucket }}" diff --git a/tests/fixtures/scenarios/locals-logical-names/stacks/deploy/prod.yaml b/tests/fixtures/scenarios/locals-logical-names/stacks/deploy/prod.yaml new file mode 100644 index 0000000000..b952a53067 --- /dev/null +++ b/tests/fixtures/scenarios/locals-logical-names/stacks/deploy/prod.yaml @@ -0,0 +1,37 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +# Vars with LITERAL values (not templates) so name_template can derive logical stack name. +# This results in logical stack name: "prod-us-west-2" (from name_template). +vars: + environment: prod + stage: us-west-2 + +# File-scoped locals. +locals: + namespace: acme + env_prefix: "{{ .locals.namespace }}-prod" + full_prefix: "{{ .locals.env_prefix }}-us-west-2" + +terraform: + locals: + backend_bucket: "{{ .locals.namespace }}-prod-tfstate" + tf_only: terraform-specific-prod + +helmfile: + locals: + release_name: "{{ .locals.namespace }}-prod-release" + hf_only: helmfile-specific-prod + +components: + terraform: + vpc: + metadata: + component: mock + vars: + name: "{{ .locals.full_prefix }}-vpc" + bucket: "{{ .locals.backend_bucket }}" + + helmfile: + nginx: + vars: + release: "{{ .locals.release_name }}" diff --git a/tests/fixtures/scenarios/locals-not-inherited/atmos.yaml b/tests/fixtures/scenarios/locals-not-inherited/atmos.yaml new file mode 100644 index 0000000000..bcfdf881a5 --- /dev/null +++ b/tests/fixtures/scenarios/locals-not-inherited/atmos.yaml @@ -0,0 +1,28 @@ +base_path: "./" + +components: + terraform: + base_path: "../../components/terraform" + +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/mixins/**" + name_template: "{{ .vars.stage }}" + +logs: + file: "/dev/stderr" + level: Info + +templates: + settings: + enabled: true + evaluations: 1 + sprig: + enabled: true + gomplate: + enabled: true + timeout: 10 + datasources: {} diff --git a/tests/fixtures/scenarios/locals-not-inherited/stacks/deploy/test.yaml b/tests/fixtures/scenarios/locals-not-inherited/stacks/deploy/test.yaml new file mode 100644 index 0000000000..07fc36e4ef --- /dev/null +++ b/tests/fixtures/scenarios/locals-not-inherited/stacks/deploy/test.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +import: + - mixins/base + +vars: + stage: "test" + +components: + terraform: + test-component: + metadata: + component: mock + vars: + # This attempts to use a mixin's local - should FAIL because locals are file-scoped. + attempt_mixin_local: "{{ .locals.mixin_value }}" diff --git a/tests/fixtures/scenarios/locals-not-inherited/stacks/mixins/base.yaml b/tests/fixtures/scenarios/locals-not-inherited/stacks/mixins/base.yaml new file mode 100644 index 0000000000..b8e014fa10 --- /dev/null +++ b/tests/fixtures/scenarios/locals-not-inherited/stacks/mixins/base.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +# This mixin defines locals that should NOT be inherited. +locals: + mixin_value: "from-mixin-locals" + +# Regular vars ARE inherited. +vars: + inherited_var: "from-mixin-vars" diff --git a/tests/fixtures/scenarios/locals/stacks/deploy/dev.yaml b/tests/fixtures/scenarios/locals/stacks/deploy/dev.yaml index 8f435cbf9e..a40577e36b 100644 --- a/tests/fixtures/scenarios/locals/stacks/deploy/dev.yaml +++ b/tests/fixtures/scenarios/locals/stacks/deploy/dev.yaml @@ -43,24 +43,19 @@ components: mock/instance-1: metadata: component: mock - # Component-level locals - inherit from terraform scope. - locals: - instance_id: "instance-1" - app_name: "{{ .locals.name_prefix }}-mock-{{ .locals.instance_id }}" vars: - foo: "{{ .locals.app_name }}" + # Use global and terraform-scope locals. + app_name: "{{ .locals.name_prefix }}-mock-instance-1" bar: "{{ .locals.environment }}" - baz: "{{ .locals.instance_id }}" + bucket: "{{ .locals.backend_bucket }}" tags: "{{ .locals.tags }}" mock/instance-2: metadata: component: mock - locals: - instance_id: "instance-2" - app_name: "{{ .locals.name_prefix }}-mock-{{ .locals.instance_id }}" vars: - foo: "{{ .locals.app_name }}" + # Use global and terraform-scope locals. + app_name: "{{ .locals.name_prefix }}-mock-instance-2" bar: "{{ .locals.environment }}" - baz: "{{ .locals.instance_id }}" + bucket: "{{ .locals.backend_bucket }}" tags: "{{ .locals.tags }}" diff --git a/tests/fixtures/scenarios/locals/stacks/deploy/prod.yaml b/tests/fixtures/scenarios/locals/stacks/deploy/prod.yaml index f19ee1a00e..b46f4b6154 100644 --- a/tests/fixtures/scenarios/locals/stacks/deploy/prod.yaml +++ b/tests/fixtures/scenarios/locals/stacks/deploy/prod.yaml @@ -35,11 +35,9 @@ components: mock/primary: metadata: component: mock - locals: - instance_id: "primary" - app_name: "{{ .locals.name_prefix }}-mock-{{ .locals.instance_id }}" vars: - foo: "{{ .locals.app_name }}" + # Use global and terraform-scope locals. + app_name: "{{ .locals.name_prefix }}-mock-primary" bar: "{{ .locals.environment }}" - baz: "{{ .locals.instance_id }}" + bucket: "{{ .locals.backend_bucket }}" tags: "{{ .locals.tags }}" diff --git a/tests/fixtures/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json b/tests/fixtures/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json index d62b907704..3c36006d42 100644 --- a/tests/fixtures/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json +++ b/tests/fixtures/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json @@ -765,7 +765,7 @@ }, "locals": { "title": "locals", - "description": "File-scoped local variables for use within templates. Locals are resolved before other sections and can reference each other within the same file. Unlike vars and settings, locals do NOT inherit across file boundaries.", + "description": "Local variables for use within templates. At file level (global, terraform, helmfile, packer sections), locals are file-scoped and do NOT inherit across file boundaries. At component level, locals inherit from base components (via 'component' attribute or 'metadata.inherits') similar to vars, with component-level locals taking precedence. Locals can reference each other within the same scope.", "oneOf": [ { "type": "string", diff --git a/website/blog/2025-12-16-file-scoped-locals.mdx b/website/blog/2025-12-16-file-scoped-locals.mdx index 47537126ad..7eee5a0318 100644 --- a/website/blog/2025-12-16-file-scoped-locals.mdx +++ b/website/blog/2025-12-16-file-scoped-locals.mdx @@ -144,11 +144,11 @@ This keeps locals truly local, preventing unexpected interactions between files. ### Multi-Level Scopes -Locals can be defined at three levels, each inheriting from its parent: +Locals can be defined at three levels, with inner scopes inheriting from outer: 1. **Global** (stack file root) - Available throughout the file 2. **Component-type** (`terraform`, `helmfile`, `packer` sections) - Inherits from global -3. **Component** (individual component) - Inherits from component-type +3. **Component-level** (inside component definitions) - Inherits from global + component-type, and from base components via `metadata.inherits` ```yaml # Global locals @@ -161,15 +161,13 @@ terraform: locals: backend_bucket: "{{ .locals.namespace }}-{{ .locals.environment }}-tfstate" - components: - terraform: - vpc: - # Component-level locals (inherit from terraform scope) - locals: - component_name: vpc - full_name: "{{ .locals.namespace }}-{{ .locals.component_name }}" - vars: - name: "{{ .locals.full_name }}" +components: + terraform: + vpc: + vars: + # Uses merged locals (global + terraform section) + name: "{{ .locals.namespace }}-{{ .locals.environment }}-vpc" + bucket: "{{ .locals.backend_bucket }}" ``` ## Inspecting Locals with `atmos describe locals` @@ -182,39 +180,16 @@ atmos describe locals vpc -s prod-ue2 ```yaml -namespace: acme -environment: prod -stage: us-east-1 -name_prefix: acme-prod -full_name: acme-prod-us-east-1 -backend_bucket: acme-prod-tfstate -component_name: vpc -tags: - Environment: prod - Namespace: acme -``` - - -### Provenance Tracking - -Add `--provenance` to see exactly where each local was defined: - -```bash -atmos describe locals vpc -s prod-ue2 --provenance -``` - - -```yaml -namespace: acme # ● [1] stacks/prod/us-east-1.yaml:4 -environment: prod # ● [1] stacks/prod/us-east-1.yaml:5 -stage: us-east-1 # ● [1] stacks/prod/us-east-1.yaml:6 -name_prefix: acme-prod # ● [1] stacks/prod/us-east-1.yaml:7 (computed) -full_name: acme-prod-us-east-1 # ● [1] stacks/prod/us-east-1.yaml:8 (computed) -backend_bucket: acme-prod-tfstate # ○ [2] terraform section:12 (computed) -component_name: vpc # ○ [3] component section:18 -tags: # ● [1] stacks/prod/us-east-1.yaml:9 - Environment: prod # ● [1] stacks/prod/us-east-1.yaml:10 (computed) - Namespace: acme # ● [1] stacks/prod/us-east-1.yaml:11 (computed) +components: + terraform: + vpc: + locals: + namespace: acme + environment: prod + stage: us-east-1 + name_prefix: acme-prod + full_name: acme-prod-us-east-1 + backend_bucket: acme-prod-tfstate ``` @@ -228,19 +203,39 @@ atmos describe locals vpc -s prod-ue2 --format json ```json { - "locals": { - "namespace": "acme", - "environment": "prod", - "name_prefix": "acme-prod" - }, - "metadata": { - "component": "vpc", - "stack": "prod-ue2", - "component_type": "terraform" + "components": { + "terraform": { + "vpc": { + "locals": { + "namespace": "acme", + "environment": "prod", + "name_prefix": "acme-prod" + } + } + } } } ``` +### Show Locals for a Stack + +With the `--stack` flag (required), show locals for the specified stack: + +```bash +atmos describe locals --stack prod-ue2 +``` + +```yaml +locals: + namespace: acme + environment: prod +terraform: + locals: + backend_bucket: acme-prod-tfstate +``` + +The output is in direct stack manifest format - it can be redirected to a file and used as valid YAML. + ## Why File-Scoped? You might wonder why locals don't inherit across imports like `vars` do. The design is intentional: @@ -296,13 +291,15 @@ terraform: # Terraform-specific - only used by terraform components locals: state_bucket: "{{ .locals.namespace }}-tfstate" + vpc_name: "{{ .locals.namespace }}-vpc" - components: - terraform: - vpc: - # Component-specific - only used by this component - locals: - vpc_name: "{{ .locals.namespace }}-vpc" +components: + terraform: + vpc: + vars: + # Uses merged locals (global + terraform section) + name: "{{ .locals.vpc_name }}" + bucket: "{{ .locals.state_bucket }}" ``` ## Get Started diff --git a/website/blog/2026-01-06-file-scoped-locals-fix.mdx b/website/blog/2026-01-06-file-scoped-locals-fix.mdx new file mode 100644 index 0000000000..15816ba3c5 --- /dev/null +++ b/website/blog/2026-01-06-file-scoped-locals-fix.mdx @@ -0,0 +1,305 @@ +--- +slug: file-scoped-locals-fix +title: 'Fixed: File-Scoped Locals Now Resolve Templates Correctly' +authors: + - aknysh +tags: + - bugfix +date: 2026-01-06T00:00:00.000Z +--- + +The file-scoped locals feature introduced in v1.203.0 now correctly resolves `{{ .locals.* }}` templates in stack configurations. +Previously, locals were defined but not integrated into the template processing pipeline, causing templates to remain unresolved. + + + +## The Problem + +When using file-scoped locals as documented, templates referencing locals were not being resolved: + +```yaml +# stacks/prod.yaml +locals: + namespace: acme + environment: prod + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" + +components: + terraform: + myapp: + vars: + name: "{{ .locals.name_prefix }}-myapp" + stage: "{{ .locals.environment }}" +``` + +Running `atmos describe component` showed raw template strings instead of resolved values: + +```yaml +# Before fix - templates not resolved +vars: + name: "{{ .locals.name_prefix }}-myapp" + stage: "{{ .locals.environment }}" +``` + +## The Fix + +Atmos now correctly resolves locals before template processing. The same configuration now produces the expected output: + +```yaml +# After fix - templates resolved correctly +vars: + name: "acme-prod-myapp" + stage: "prod" +``` + +### What Changed + +1. **Locals extraction** - Raw YAML is now parsed to extract `locals:` sections before template processing +2. **Template context** - Resolved locals are added to the template context so `{{ .locals.* }}` references work +3. **Section override tracking** - Section-specific locals (in `terraform:`, `helmfile:`, `packer:`) correctly override global locals +4. **Component-level locals** - Components can now define their own `locals:` section that inherits from base components + +### New Command: `atmos describe locals` + +A new command has been added to help inspect and debug locals configurations: + +```bash +# Show locals for a specific stack (using file path) +atmos describe locals --stack deploy/dev + +# Show locals for a specific stack (using logical stack name from atmos.yaml) +atmos describe locals --stack prod-us-east-1 + +# Show locals available to a specific component in a stack +atmos describe locals vpc -s prod + +# Output as JSON +atmos describe locals -s dev --format json + +# Write to file +atmos describe locals -s dev --file locals.yaml +``` + +**Note:** The `--stack` flag is required. Locals are file-scoped, so a specific stack must be specified. + +The `--stack` flag accepts either a **stack manifest file path** (e.g., `deploy/dev`) or a **logical stack name** derived from your `atmos.yaml` naming pattern (e.g., `prod-us-east-1`). Both resolve to the same underlying file, and locals are returned from that file only. + +#### Component-Specific Locals + +When specifying a component with the `--stack` flag, the command shows the merged locals that would be **available to** that component during template processing. This includes global locals, section-specific locals (for the component's type), and component-level locals (including inherited from base components): + +```bash +atmos describe locals vpc -s prod +``` + +```yaml +components: + terraform: + vpc: + locals: + namespace: acme + environment: prod + backend_bucket: acme-prod-tfstate +``` + +The output uses Atmos schema format, matching the structure of stack manifest files. + +#### Stack-Level Output + +Without a component argument, the output is in direct stack manifest format: + +```yaml +locals: + namespace: acme + environment: dev + name_prefix: acme-dev +terraform: + locals: + backend_bucket: acme-dev-tfstate +``` + +- **locals** - Global locals defined at root level of the stack file +- **terraform/helmfile/packer** - Section-specific locals nested under `{ locals: }` (only shown if defined) + +The output is in direct stack manifest format - it can be redirected to a file and used as valid YAML (e.g., `atmos describe locals -s dev --file locals.yaml`). + +### Section-Specific Locals + +Locals can be defined at multiple levels, with later scopes overriding earlier ones: + +```yaml +# Global locals +locals: + namespace: "global-acme" + +terraform: + # Terraform-scope locals override global + locals: + namespace: "terraform-acme" + backend_bucket: "{{ .locals.namespace }}-tfstate" + +components: + terraform: + myapp: + vars: + # Uses terraform-scope value: "terraform-acme-tfstate" + bucket: "{{ .locals.backend_bucket }}" +``` + +## Features That Work + +All documented locals features now function correctly: + +### Component-Level Locals with Inheritance + +```yaml +components: + terraform: + vpc/base: + locals: + vpc_type: "standard" + cidr_prefix: "10.0" + + vpc/prod: + metadata: + inherits: + - vpc/base + locals: + vpc_type: "production" # Overrides base + vars: + cidr: "{{ .locals.cidr_prefix }}.0.0/16" # Inherited from base +``` + +### Locals Referencing Other Locals + +```yaml +locals: + namespace: acme + environment: prod + # Resolved in dependency order + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" + full_name: "{{ .locals.name_prefix }}-us-east-1" +``` + +### Circular Dependency Detection + +Atmos detects circular dependencies and logs them gracefully: + +```yaml +# This triggers a circular dependency warning +locals: + a: "{{ .locals.b }}" + b: "{{ .locals.a }}" +``` + +### Complex Values + +Maps and nested structures work as expected: + +```yaml +locals: + common_tags: + Environment: "{{ .locals.environment }}" + Namespace: "{{ .locals.namespace }}" + +components: + terraform: + vpc: + vars: + tags: "{{ .locals.common_tags }}" +``` + +## Supported Scopes + +Locals can be defined at three levels: + +```yaml +# Global locals (root level) - available throughout the file +locals: + namespace: acme + environment: prod + +# Section-level locals (terraform/helmfile/packer) - inherit from global +terraform: + locals: + backend_bucket: "{{ .locals.namespace }}-{{ .locals.environment }}-tfstate" + +components: + terraform: + vpc: + # Component-level locals - inherit from global and section, plus base components + locals: + vpc_type: "production" + vars: + # Uses merged locals (global + terraform section + component) + bucket: "{{ .locals.backend_bucket }}" + type: "{{ .locals.vpc_type }}" +``` + +### Component-Level Locals Inheritance + +Component-level locals support inheritance from base components via `metadata.inherits` or `component` attribute, similar to how `vars` work: + +```yaml +components: + terraform: + vpc/base: + metadata: + type: abstract + locals: + vpc_type: "standard" + cidr_prefix: "10.0" + + vpc/prod: + metadata: + inherits: + - vpc/base + locals: + # Overrides base component's vpc_type + vpc_type: "production" + vars: + # Uses inherited cidr_prefix from base, overridden vpc_type + cidr: "{{ .locals.cidr_prefix }}.0.0/16" + name: "{{ .locals.vpc_type }}-vpc" +``` + +**Full locals resolution order for a component:** + +``` +Global Locals → Section Locals → Base Component Locals → Component Locals +``` + +**Note:** File-scoped locals (global and section-level) do NOT inherit across file boundaries. Only component-level locals inherit from base components. + +## Upgrade + +Upgrade Atmos to get this fix. No configuration changes are required. +Your existing `locals:` definitions will automatically start working. + +:::note Reserved Context Key +The `locals` key in template context is now reserved for file-scoped locals. If you previously used a `locals` key in import context (via the `context:` parameter), it will be overridden by file-scoped locals when present. This is unlikely to affect existing configurations since the `locals` feature is new. +::: + +:::warning Template Processing with Locals +When a file defines locals, template processing is automatically enabled. If your YAML files contain non-Atmos template syntax (e.g., Helm's `{{ ... }}`), use `skip_templates_processing: true` in the import to preserve literal syntax: + +```yaml +import: + - path: catalog/helm-values + skip_templates_processing: true +``` +::: + +```bash +# View locals for a specific stack +atmos describe locals -s prod + +# Verify locals are resolving correctly in component output +atmos describe component myapp -s prod --format yaml +``` + +## References + +- [GitHub Issue #1933](https://github.com/cloudposse/atmos/issues/1933) +- [File-Scoped Locals Documentation](/stacks/locals) +- [Original Feature Announcement](/changelog/file-scoped-locals) diff --git a/website/docs/cli/commands/describe/describe-locals.mdx b/website/docs/cli/commands/describe/describe-locals.mdx new file mode 100644 index 0000000000..475650d4a5 --- /dev/null +++ b/website/docs/cli/commands/describe/describe-locals.mdx @@ -0,0 +1,283 @@ +--- +title: atmos describe locals +sidebar_label: locals +sidebar_class_name: command +id: locals +description: Use this command to display the locals defined in Atmos stack manifests. +--- +import Terminal from '@site/src/components/Terminal' +import Screengrab from '@site/src/components/Screengrab' +import Intro from '@site/src/components/Intro' + + +Use this command to display the [locals](/stacks/locals) defined in Atmos stack manifests. +This is useful for debugging and understanding how locals are configured in a specific stack. + + +:::info Stack Flag Required +The `--stack` flag is **required**. Atmos resolves it to a stack manifest file and returns **only the locals defined in that file** (not inherited from imports). The `--stack` flag accepts either: +- A **logical stack name** derived from your `atmos.yaml` naming pattern (e.g., `prod-us-east-1`) +- A **stack manifest file path** (e.g., `deploy/prod`) + +Both resolve to the same underlying file. Locals are file-scoped, so the output reflects what's defined in that specific manifest. +::: + + + +## Usage + +Execute the `describe locals` command like this: + +```shell +atmos describe locals [component] -s [options] +``` + +The `--stack` flag is required. When called with just `--stack`, it shows the locals defined in that stack manifest file. +When a component is also specified, it shows the merged locals that would be **available to** that component. This includes: +- **Global locals** from the stack manifest file +- **Section-specific locals** (e.g., `terraform:` locals for a Terraform component) +- **Component-level locals** defined in the component itself (including inherited from base components) + +:::tip +Run `atmos describe locals --help` to see all the available options +::: + +## Examples + +```shell +# Show locals for a specific stack (using file path) +atmos describe locals --stack deploy/dev + +# Show locals for a specific stack (using logical stack name derived from atmos.yaml) +atmos describe locals -s prod-us-east-1 + +# Show locals available to a specific component in a stack +# The component determines which section-specific locals to merge (terraform/helmfile/packer) +atmos describe locals vpc -s prod +atmos describe locals eks --stack prod-us-east-1 + +# Output as JSON +atmos describe locals -s dev --format json +atmos describe locals vpc -s prod -f json + +# Write to file +atmos describe locals -s dev --file locals.yaml + +# Query specific values +atmos describe locals -s deploy/dev --query '.locals.namespace' +``` + +## Arguments + +
+
`component` (optional)
+
The name of a component. When specified with `--stack`, shows the merged locals that would be available to that component. Atmos determines the component's type (terraform, helmfile, or packer) and merges: (1) global locals, (2) section-specific locals from the stack manifest, and (3) component-level locals defined in the component itself (including those inherited from base components via metadata.inherits).
+
+ +## Flags + +
+
`--stack` / `-s` (required)
+
Specify the stack to show locals for. Accepts two formats: (1) Stack manifest file path - direct path relative to your stacks directory (e.g., deploy/dev, prod), or (2) Logical stack name - the derived name based on your atmos.yaml naming pattern (e.g., prod-us-east-1). Atmos resolves either format to the underlying stack manifest file and returns only the locals defined in that file.
+ +
`--format` / `-f` (optional)
+
Output format: `yaml` or `json` (`yaml` is default).
+ +
`--file` (optional)
+
If specified, write the result to the file.
+ +
`--query` / `-q` (optional)
+
Query the results of the command using `yq` expressions.

`atmos describe locals --query `

For more details, refer to https://mikefarah.gitbook.io/yq.
+
+ +## Output + +The command outputs locals in **Atmos schema format**, matching the structure of stack manifest files. Each stack contains: + +
+
`locals`
+
Root-level locals defined at the top of the stack manifest file.
+ +
`terraform.locals`
+
Locals defined within the `terraform:` section (only shown if explicitly defined). Contains only section-specific locals, not merged with global.
+ +
`helmfile.locals`
+
Locals defined within the `helmfile:` section (only shown if explicitly defined). Contains only section-specific locals, not merged with global.
+ +
`packer.locals`
+
Locals defined within the `packer:` section (only shown if explicitly defined). Contains only section-specific locals, not merged with global.
+
+ +This schema-compliant format makes it easy to compare with your source stack manifests and use the output programmatically. + +## Example Output + + +```yaml +locals: + environment: dev + namespace: acme + name_prefix: acme-dev + full_name: acme-dev-us-east-1 + tags: + Environment: dev + Namespace: acme +terraform: + locals: + backend_bucket: acme-dev-tfstate + tf_specific: terraform-only +``` + + +The output follows the same structure as stack manifest files, making it easy to understand which locals are defined where. This format can be directly used as a valid stack manifest file (e.g., `atmos describe locals -s dev --file locals.yaml`). + +### Component-Specific Output + +When a component is specified with `--stack`, the output shows the merged locals that would be **available to** that component, using Atmos schema format: + + +```yaml +components: + terraform: + vpc: + locals: + backend_bucket: acme-prod-tfstate + environment: prod + full_name: acme-prod-us-east-1 + name_prefix: acme-prod + namespace: acme + tf_specific: terraform-only + vpc_type: production +``` + + +The output shows the merged locals from all sources: +1. Global locals from the stack manifest (`locals:`) +2. Section-specific locals from the stack manifest (`terraform.locals:`) +3. Component-level locals from the component definition (including inherited from base components) + +:::tip Component-Level Locals +If the component defines its own `locals:` section (or inherits locals from a base component via `metadata.inherits`), those are included in the output and take precedence over stack-level locals. +::: + +## How Locals Work + +Locals are file-scoped variables that can reference each other and are resolved before template processing. They provide a way to define computed values that can be used throughout the stack manifest. + +### Defining Locals + +```yaml +# stacks/deploy/dev.yaml +locals: + namespace: acme + environment: dev + # Locals can reference other locals + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" + backend_bucket: "{{ .locals.name_prefix }}-tfstate" + +components: + terraform: + vpc: + vars: + # Use locals in component vars + name: "{{ .locals.name_prefix }}-vpc" + bucket: "{{ .locals.backend_bucket }}" +``` + +### Section-Specific Locals + +Locals can be defined at section level (terraform, helmfile, packer) to override global locals: + +```yaml +locals: + namespace: global-acme + +terraform: + locals: + # Overrides global namespace for terraform components + namespace: terraform-acme + backend_bucket: "{{ .locals.namespace }}-tfstate" +``` + +### Component-Level Locals + +Components can define their own `locals:` section. Component-level locals are merged with stack-level locals (global + section-specific) and take precedence. Component-level locals also support inheritance from base components via `metadata.inherits`: + +```yaml +components: + terraform: + # Base component with component-level locals + vpc/base: + metadata: + type: abstract + locals: + vpc_type: standard + cidr_prefix: "10.0" + + # Component inheriting locals from base + vpc/prod: + metadata: + inherits: + - vpc/base + locals: + # Overrides vpc_type from base component + vpc_type: production + vars: + # Uses inherited cidr_prefix from base + cidr: "{{ .locals.cidr_prefix }}.0.0/16" +``` + +The full locals resolution order for a component is: + +``` +Global Locals → Section Locals → Base Component Locals → Component Locals +``` + +Later values override earlier ones. When you run `atmos describe locals vpc/prod -s dev`, the output shows the final merged result. + +:::note +Component-level locals appear in the final component output but are NOT available during `{{ .locals.* }}` template processing in the same stack manifest file. Only file-level locals (global + section) are available during template resolution within the file. +::: + +### File-Scoped Behavior + +Locals are **file-scoped** and are NOT inherited across imports. Each stack manifest file can only access its own locally defined `locals` section. This prevents unintended side effects from imported files. + +This is a key design principle: when you run `atmos describe locals --stack deploy/dev`, you get **only** the locals defined in the `deploy/dev.yaml` file itself, regardless of what files it imports. The `--stack` flag accepts either: +- The file path (`deploy/dev`) +- The logical stack name derived from your naming pattern (e.g., `dev-us-east-1`) + +Both resolve to the same file and return the same locals. + +```yaml +# mixins/base.yaml +locals: + mixin_value: "from-mixin" # NOT available to importing files + +# stacks/deploy/dev.yaml +import: + - mixins/base + +locals: + file_value: "from-file" # Available in this file + +components: + terraform: + myapp: + vars: + value: "{{ .locals.file_value }}" # Works: "from-file" + mixin: "{{ .locals.mixin_value }}" # Does NOT work: "" +``` + +:::note +Regular `vars` ARE inherited across imports (normal Atmos behavior). Only `locals` are file-scoped. +::: + +## Related Commands + +- [`atmos describe component`](/cli/commands/describe/component) - Describe a component's full configuration including resolved locals +- [`atmos describe stacks`](/cli/commands/describe/stacks) - Describe all stacks and their configurations + +## Related Documentation + +- [File-Scoped Locals](/stacks/locals) - Learn more about defining and using locals in stack manifests diff --git a/website/docs/stacks/locals.mdx b/website/docs/stacks/locals.mdx index d32cd0fc54..80b2104ab5 100644 --- a/website/docs/stacks/locals.mdx +++ b/website/docs/stacks/locals.mdx @@ -72,9 +72,9 @@ terraform: key: "{{ .locals.backend_key_prefix }}/terraform.tfstate" ``` -### Component Level +### Using Locals in Components -Locals defined within a component inherit from the component-type scope: +Components can reference merged locals (global + component-type) in their `vars`. When the same key is defined at multiple scopes, later scopes take precedence: component-type locals override global locals, and component-level locals override both. ```yaml # stacks/orgs/acme/plat/prod/us-east-1.yaml @@ -83,25 +83,29 @@ locals: environment: prod name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" +terraform: + locals: + backend_bucket: "{{ .locals.namespace }}-{{ .locals.environment }}-tfstate" + components: terraform: vpc: - locals: - # Inherits name_prefix from global - vpc_name: "{{ .locals.name_prefix }}-vpc" - cidr_prefix: "10.0" vars: - vpc_name: "{{ .locals.vpc_name }}" - vpc_cidr: "{{ .locals.cidr_prefix }}.0.0/16" + # Uses merged locals (global + terraform section) + vpc_name: "{{ .locals.name_prefix }}-vpc" + bucket: "{{ .locals.backend_bucket }}" eks: - locals: - # Each component has its own locals scope - cluster_name: "{{ .locals.name_prefix }}-eks" vars: - cluster_name: "{{ .locals.cluster_name }}" + # Same merged locals available to all terraform components + cluster_name: "{{ .locals.name_prefix }}-eks" + bucket: "{{ .locals.backend_bucket }}" ``` +:::tip Component-Level Locals +Components can also define their own `locals:` section. Component-level locals inherit from global and section-level locals, and also support inheritance from base components via `metadata.inherits`. See the [describe locals command](/cli/commands/describe/locals) for inspecting component-level locals. +::: + ## Scope Inheritance (Within a File) Within a single file, locals follow this inheritance chain: @@ -111,7 +115,7 @@ Global locals ↓ Component-type locals (terraform/helmfile/packer) ↓ -Component locals +Component-level locals (inside component definitions) ``` Each level can: @@ -119,6 +123,16 @@ Each level can: - Define new locals - Override parent locals with new values +Component-level locals also inherit from base components via `metadata.inherits` or `component` attribute. + +:::note Precedence +When the same key exists at multiple levels, the most specific scope wins. For terraform components, the full precedence chain is: + +**Global → Terraform Section → Base Component → Component** + +This means component-level locals override section-level, which override global locals. +::: + ### Example ```yaml @@ -133,10 +147,10 @@ terraform: components: terraform: vpc: - locals: - component: vpc - # Can access: env (= "production"), tf_version (= "1.5") - full_name: "{{ .locals.env }}-{{ .locals.component }}" + vars: + # Uses merged locals: env = "production", tf_version = "1.5" + name: "{{ .locals.env }}-vpc" + terraform_version: "{{ .locals.tf_version }}" ``` ## File-Scoped Isolation @@ -292,9 +306,11 @@ vars: tags: "{{ .locals.default_tags }}" terraform: - # Terraform-specific locals + # Terraform-specific locals (inherit from global) locals: backend_bucket: "{{ .locals.namespace }}-{{ .locals.environment }}-tfstate" + vpc_name: "{{ .locals.full_name }}-vpc" + cluster_name: "{{ .locals.full_name }}-eks" backend_type: s3 backend: @@ -303,12 +319,10 @@ terraform: region: "{{ .locals.stage }}" key: terraform.tfstate +# Components use merged locals (global + terraform section) components: terraform: vpc: - locals: - # Component-specific local - vpc_name: "{{ .locals.full_name }}-vpc" vars: name: "{{ .locals.vpc_name }}" cidr_block: "10.0.0.0/16" @@ -316,8 +330,6 @@ components: Name: "{{ .locals.vpc_name }}" eks: - locals: - cluster_name: "{{ .locals.full_name }}-eks" vars: cluster_name: "{{ .locals.cluster_name }}" tags: @@ -337,6 +349,25 @@ components: Use `locals` for intermediate computations and `vars` for values that need to be passed to your components. +## Template Processing Behavior + +When a file defines locals, template processing is automatically enabled for that file. This means any `{{ ... }}` syntax in the file will be processed as Go templates. + +:::warning Conflicting Template Syntax +If your YAML files contain non-Atmos template syntax (such as Helm's `{{ ... }}`), adding locals will cause those templates to be processed incorrectly. To prevent this, you can disable template processing for specific imports: + +```yaml +import: + - path: catalog/helm-values + skip_templates_processing: true # Preserves {{ ... }} syntax as literal text +``` + +This is useful when importing files that contain: +- Helm chart values with Go template syntax +- Other templating systems that use `{{ }}` +- Literal strings that happen to contain template-like patterns +::: + ## Best Practices 1. **Use for Repetition:** If you find yourself repeating the same value or expression, extract it to a local. diff --git a/website/static/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json b/website/static/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json index d62b907704..0c695d06fb 100644 --- a/website/static/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json +++ b/website/static/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json @@ -765,7 +765,7 @@ }, "locals": { "title": "locals", - "description": "File-scoped local variables for use within templates. Locals are resolved before other sections and can reference each other within the same file. Unlike vars and settings, locals do NOT inherit across file boundaries.", + "description": "Local variables for use within templates. At file level (global, terraform, helmfile, packer sections), locals are file-scoped and do NOT inherit across file imports. At component level, locals inherit from base components via metadata.inherits (or the component field), similar to vars, with component-level locals taking precedence. Locals can reference each other within the same scope.", "oneOf": [ { "type": "string",