diff --git a/docs/prd/file-scoped-locals.md b/docs/prd/file-scoped-locals.md new file mode 100644 index 0000000000..5b22e1c64c --- /dev/null +++ b/docs/prd/file-scoped-locals.md @@ -0,0 +1,1840 @@ +# PRD: File-Scoped Locals in Atmos Stack Configuration + +## Overview + +This PRD defines a new `locals` section for Atmos stack configuration files that provides file-scoped variable definitions for cleaner, more maintainable configurations—similar to Terraform and Terragrunt locals. + +## Problem Statement + +Currently, when users need to define reusable values within a stack configuration file, they must either: +1. Duplicate values across multiple places in the file +2. Use `vars` or `settings` sections, which have different semantics (vars are passed to Terraform, settings are inherited across files) +3. Create additional files just to hold shared values + +This leads to verbose, repetitive configurations that are harder to maintain and more error-prone. + +## Solution + +Introduce a `locals` section that: +- Is **file-scoped only** (never inherited across file boundaries via imports) +- Can be defined at multiple scopes within a file: global, component-type (terraform/helmfile), and component level +- Is resolved **before** other sections, making local values available for use in `vars`, `settings`, `env`, etc. +- Is **not** passed to Terraform/Helmfile (unlike `vars`) +- Is **not** visible in `atmos describe component` output (unlike `settings`) + +## User Experience + +### Basic Usage + +```yaml +# stacks/catalog/vpc.yaml +locals: + base_cidr: "10.0.0.0" + environment_suffix: "prod" + full_name: "{{ .locals.base_cidr }}-{{ .locals.environment_suffix }}" + +components: + terraform: + vpc: + vars: + cidr_block: "{{ .locals.base_cidr }}/16" + name: "vpc-{{ .locals.environment_suffix }}" +``` + +### Locals Referencing Other Locals + +Locals can reference other locals within the same scope. References are resolved using topological sorting with cycle detection: + +```yaml +locals: + # Base values + project: "myapp" + environment: "prod" + region: "us-east-1" + + # Derived values (reference other locals) + prefix: "{{ .locals.project }}-{{ .locals.environment }}" + full_prefix: "{{ .locals.prefix }}-{{ .locals.region }}" + bucket_name: "{{ .locals.full_prefix }}-assets" + +components: + terraform: + s3: + vars: + bucket: "{{ .locals.bucket_name }}" # "myapp-prod-us-east-1-assets" +``` + +**Order doesn't matter** - locals can be defined in any order. The system builds a dependency graph and resolves them in the correct order: + +```yaml +locals: + # These work regardless of definition order + c: "{{ .locals.a }}-{{ .locals.b }}" # Depends on a and b + a: "first" # No dependencies + b: "{{ .locals.a }}-second" # Depends on a + # Resolution order: a → b → c +``` + +**Circular references are detected and reported:** + +```yaml +locals: + # ❌ Error: circular dependency detected: a → b → c → a + a: "{{ .locals.c }}" + b: "{{ .locals.a }}" + c: "{{ .locals.b }}" +``` + +### Scoped Locals + +```yaml +# Global locals (available to all components in this file) +locals: + region: "us-east-1" + account_id: "123456789012" + +# Terraform-wide locals (available to all terraform components in this file) +terraform: + locals: + state_bucket: "terraform-state-{{ .locals.account_id }}" + + vars: + backend_bucket: "{{ .locals.state_bucket }}" + +# Component-specific locals +components: + terraform: + vpc: + locals: + vpc_name: "main-vpc-{{ .locals.region }}" + vars: + name: "{{ .locals.vpc_name }}" + tags: + Name: "{{ .locals.vpc_name }}" +``` + +### Locals Do NOT Inherit Across Files + +```yaml +# stacks/_defaults.yaml +locals: + shared_value: "from-defaults" # This is ONLY available in _defaults.yaml + +vars: + some_var: "{{ .locals.shared_value }}" # Works - same file + +# stacks/deploy/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 (it was in _defaults.yaml, not inherited) + # bad_ref: "{{ .locals.shared_value }}" +``` + +### Using YAML Functions in Locals + +```yaml +locals: + # Static values + base_name: "myapp" + + # Computed from environment + aws_region: !env AWS_REGION + + # Computed from other sources + vpc_id: !terraform.output vpc/outputs/vpc_id + + # Templated values + full_arn: !template "arn:aws:s3:::{{ .locals.base_name }}-{{ .locals.aws_region }}" + +components: + terraform: + my-component: + vars: + vpc_id: "{{ .locals.vpc_id }}" +``` + +## Scope Resolution Order + +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 + +```yaml +locals: + global_val: "global" + +terraform: + locals: + tf_val: "{{ .locals.global_val }}-terraform" + +components: + terraform: + vpc: + locals: + component_val: "{{ .locals.tf_val }}-vpc" + vars: + name: "{{ .locals.component_val }}" # Results in "global-terraform-vpc" +``` + +## Behavior Clarifications + +### What Locals Are NOT + +| 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 | + +### Error Handling + +1. **Reference to undefined local**: Clear error message indicating the local doesn't exist + ```text + Error: undefined local "foo" referenced in stacks/deploy/prod.yaml + + Available locals in this file: + - bar + - baz + + Hint: Locals are file-scoped and do not inherit from imported files. + ``` + +2. **Reference to local from imported file**: Clear error explaining locals don't inherit + ```text + Error: undefined local "shared_value" referenced in stacks/deploy/prod.yaml + + Hint: "shared_value" is defined in stacks/_defaults.yaml but locals do not + inherit across files. Consider using vars or settings if cross-file sharing + is needed. + ``` + +3. **Circular reference**: Detected and reported with clear dependency chain + ```text + Error: circular dependency in locals at stacks/deploy/prod.yaml + + Dependency cycle detected: + a → b → c → a + + Referenced locals: + a: "{{ .locals.c }}" (line 5) + b: "{{ .locals.a }}" (line 6) + c: "{{ .locals.b }}" (line 7) + ``` + +### Edge Cases + +**Empty locals section**: Valid, no-op +```yaml +locals: {} +``` + +**Locals referencing vars/settings**: Allowed, but vars/settings must be resolvable at that point +```yaml +vars: + base: "value" + +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 +```yaml +# catalog/base.yaml +components: + terraform: + base-vpc: + locals: + base_local: "value" # NOT inherited + +# deploy/prod.yaml +components: + terraform: + vpc: + metadata: + inherits: + - base-vpc + vars: + # ❌ Error - base_local is not available (was in catalog/base.yaml) + # name: "{{ .locals.base_local }}" +``` + +## Implementation Considerations + +### Processing Order + +1. Parse YAML file +2. **Extract locals at each scope (global → component-type → component)** +3. **Build dependency graph for locals within each scope** +4. **Topologically sort and resolve locals (with cycle detection)** +5. Make resolved locals available in template context +6. Process remaining sections (vars, settings, env, etc.) with locals in context +7. Continue normal stack processing (imports, inheritance, merging) + +### Locals Resolution Algorithm + +For each scope (global, component-type, component): + +1. **Parse phase**: Extract all local definitions without resolving templates +2. **Dependency extraction**: Scan each local's value for `{{ .locals.X }}` references +3. **Graph construction**: Build directed graph where edges represent dependencies +4. **Cycle detection**: Use DFS to detect cycles; if found, report error with full cycle path +5. **Topological sort**: Order locals so dependencies are resolved before dependents +6. **Resolution phase**: Resolve locals in sorted order, making each available for subsequent locals + +``` +Example: locals = {c: "{{.locals.b}}", a: "val", b: "{{.locals.a}}"} + +1. Dependencies: c→[b], a→[], b→[a] +2. Topological order: [a, b, c] +3. Resolve: + - a = "val" + - b = "val" (a is now available) + - c = "val" (b is now available) +``` + +### Cross-Scope Local References + +Inner scopes inherit resolved locals from outer scopes: + +```yaml +locals: + global_val: "global" # Scope 1: global + +terraform: + locals: + tf_val: "{{ .locals.global_val }}-tf" # Scope 2: can see global_val + +components: + terraform: + vpc: + locals: + comp_val: "{{ .locals.tf_val }}-vpc" # Scope 3: 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) + +### Template Context + +Locals should be available via `.locals` in Go templates: +```yaml +locals: + name: "example" + +vars: + full_name: "{{ .locals.name }}-component" +``` + +### Schema Updates + +The JSON schema needs updates to allow `locals` at: +- Stack root level +- `terraform` section +- `helmfile` section +- `packer` section +- Individual component definitions + +## Success Criteria + +1. Users can define file-scoped variables that don't pollute `vars` or `settings` +2. Locals are clearly file-scoped and don't leak across import boundaries +3. Clear, actionable error messages for common mistakes +4. Performance impact is minimal (locals resolved once per file) +5. Works with existing YAML functions (`!template`, `!env`, `!exec`, etc.) + +## Non-Goals (Out of Scope) + +- Cross-file local sharing (use `vars` or `settings` for that) +- Lazy evaluation of locals (all resolved upfront) +- Locals in `atmos.yaml` (stack files only) +- Export locals to child files (opposite of file-scoped) + +## Design Decisions + +### Locals NOT Available in Imports + +Locals are processed **after** imports are resolved. This keeps the import system simple and predictable: + +```yaml +# ❌ NOT supported - imports are resolved before locals +locals: + env: "prod" + +import: + - "catalog/{{ .locals.env }}/base.yaml" # Error: locals not available here +``` + +If dynamic imports are needed, use environment variables or template context instead. + +### No Special "Promote" Syntax + +To use a local value in vars/settings, use standard template syntax. No special `!local` function: + +```yaml +locals: + computed: "value" + +vars: + my_var: "{{ .locals.computed }}" # Standard approach +``` + +### No Name Collision (Separate Namespaces) + +Locals and vars exist in separate namespaces (`.locals.*` vs `.vars.*`), so there's no collision: + +```yaml +locals: + name: "local-value" + +vars: + name: "var-value" + +components: + terraform: + example: + vars: + from_local: "{{ .locals.name }}" # "local-value" + from_var: "{{ .vars.name }}" # "var-value" +``` + +### All YAML Functions Supported + +Locals support the same YAML functions as vars (`!template`, `!env`, `!exec`, `!terraform.output`, `!terraform.state`, `!store.get`). Since locals are resolved once per file, expensive operations are cached: + +```yaml +locals: + vpc_id: !terraform.output vpc/outputs/vpc_id + region: !env AWS_REGION + +components: + terraform: + app1: + vars: + vpc_id: "{{ .locals.vpc_id }}" # Reuses cached value + app2: + vars: + vpc_id: "{{ .locals.vpc_id }}" # Same cached value +``` + +## Technical Implementation + +### New Package: `pkg/template/` - Template AST Utilities + +The locals feature requires robust Go template AST inspection. Rather than adding this to `internal/exec/template_utils.go` (which already has basic inspection via `IsGolangTemplate()`), we should create a dedicated `pkg/template/` package that: + +1. **Consolidates template AST utilities** - Reusable across the codebase +2. **Follows architectural guidance** - "prefer `pkg/` over `internal/exec/`" +3. **Enables future enhancements** - Deferred evaluation, template validation, etc. + +#### `pkg/template/ast.go` - Template AST Inspection + +```go +package template + +import ( + "text/template" + "text/template/parse" +) + +// FieldRef represents a reference to a field in a template (e.g., .locals.foo). +type FieldRef struct { + Path []string // e.g., ["locals", "foo"] for .locals.foo +} + +// ExtractFieldRefs parses a Go template string and extracts all field references. +// Handles complex expressions: conditionals, pipes, range, with blocks, nested templates. +func ExtractFieldRefs(templateStr string) ([]FieldRef, error) { + tmpl, err := template.New("").Parse(templateStr) + if err != nil { + return nil, err + } + + if tmpl.Tree == nil || tmpl.Tree.Root == nil { + return nil, nil + } + + var refs []FieldRef + seen := make(map[string]bool) + + walkAST(tmpl.Tree.Root, func(node parse.Node) { + if field, ok := node.(*parse.FieldNode); ok { + key := fieldKey(field.Ident) + if !seen[key] { + refs = append(refs, FieldRef{Path: field.Ident}) + seen[key] = true + } + } + }) + + return refs, nil +} + +// ExtractFieldRefsByPrefix extracts field references that start with a specific prefix. +// For example, ExtractFieldRefsByPrefix(tmpl, "locals") returns all .locals.X references. +func ExtractFieldRefsByPrefix(templateStr string, prefix string) ([]string, error) { + refs, err := ExtractFieldRefs(templateStr) + if err != nil { + return nil, err + } + + var result []string + for _, ref := range refs { + if len(ref.Path) >= 2 && ref.Path[0] == prefix { + result = append(result, ref.Path[1]) + } + } + return result, nil +} + +// walkAST traverses all nodes in a template AST, calling fn for each node. +func walkAST(node parse.Node, fn func(parse.Node)) { + if node == nil { + return + } + + fn(node) + + switch n := node.(type) { + case *parse.ListNode: + if n != nil { + for _, child := range n.Nodes { + walkAST(child, fn) + } + } + + case *parse.ActionNode: + walkAST(n.Pipe, fn) + + case *parse.PipeNode: + if n != nil { + for _, cmd := range n.Cmds { + walkAST(cmd, fn) + } + for _, decl := range n.Decl { + walkAST(decl, fn) + } + } + + case *parse.CommandNode: + if n != nil { + for _, arg := range n.Args { + walkAST(arg, fn) + } + } + + case *parse.IfNode: + walkAST(n.Pipe, fn) + walkAST(n.List, fn) + walkAST(n.ElseList, fn) + + case *parse.RangeNode: + walkAST(n.Pipe, fn) + walkAST(n.List, fn) + walkAST(n.ElseList, fn) + + case *parse.WithNode: + walkAST(n.Pipe, fn) + walkAST(n.List, fn) + walkAST(n.ElseList, fn) + + case *parse.TemplateNode: + walkAST(n.Pipe, fn) + + case *parse.BranchNode: + walkAST(n.Pipe, fn) + walkAST(n.List, fn) + walkAST(n.ElseList, fn) + } +} + +func fieldKey(ident []string) string { + key := "" + for i, s := range ident { + if i > 0 { + key += "." + } + key += s + } + return key +} + +// HasTemplateActions checks if a string contains Go template actions. +// This is a more robust version of the existing IsGolangTemplate in internal/exec. +func HasTemplateActions(str string) (bool, error) { + tmpl, err := template.New("").Parse(str) + if err != nil { + return false, err + } + + if tmpl.Tree == nil || tmpl.Tree.Root == nil { + return false, nil + } + + hasActions := false + walkAST(tmpl.Tree.Root, func(node parse.Node) { + switch node.(type) { + case *parse.ActionNode, *parse.IfNode, *parse.RangeNode, *parse.WithNode: + hasActions = true + } + }) + + return hasActions, nil +} +``` + +#### `pkg/template/ast_test.go` - Comprehensive Tests + +```go +package template + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractFieldRefsByPrefix(t *testing.T) { + tests := []struct { + name string + template string + prefix string + expected []string + }{ + { + name: "simple field", + template: "{{ .locals.foo }}", + prefix: "locals", + expected: []string{"foo"}, + }, + { + name: "multiple fields", + template: "{{ .locals.foo }}-{{ .locals.bar }}", + prefix: "locals", + expected: []string{"foo", "bar"}, + }, + { + name: "conditional with multiple refs", + template: "{{ if .locals.flag }}{{ .locals.x }}{{ else }}{{ .locals.y }}{{ end }}", + prefix: "locals", + expected: []string{"flag", "x", "y"}, + }, + { + name: "pipe expression", + template: `{{ .locals.foo | printf "%s-%s" .locals.bar }}`, + prefix: "locals", + expected: []string{"foo", "bar"}, + }, + { + name: "range block", + template: "{{ range .locals.items }}{{ .locals.prefix }}-{{ . }}{{ end }}", + prefix: "locals", + expected: []string{"items", "prefix"}, + }, + { + name: "with block - context change", + template: "{{ with .locals.config }}{{ .name }}{{ end }}", + prefix: "locals", + expected: []string{"config"}, // .name is NOT .locals.name + }, + { + name: "mixed prefixes", + template: "{{ .locals.a }}-{{ .vars.b }}-{{ .settings.c }}", + prefix: "locals", + expected: []string{"a"}, + }, + { + name: "nested conditionals", + template: "{{ if .locals.a }}{{ if .locals.b }}{{ .locals.c }}{{ end }}{{ end }}", + prefix: "locals", + expected: []string{"a", "b", "c"}, + }, + { + name: "no template syntax", + template: "just a plain string", + prefix: "locals", + expected: nil, + }, + { + name: "deep path", + template: "{{ .locals.config.nested.value }}", + prefix: "locals", + expected: []string{"config"}, // Only first level after prefix + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ExtractFieldRefsByPrefix(tt.template, tt.prefix) + assert.NoError(t, err) + + // Sort for deterministic comparison + sort.Strings(result) + sort.Strings(tt.expected) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasTemplateActions(t *testing.T) { + tests := []struct { + template string + expected bool + }{ + {"{{ .foo }}", true}, + {"{{ if .x }}y{{ end }}", true}, + {"{{ range .items }}{{ . }}{{ end }}", true}, + {"plain text", false}, + {"no {{ braces", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.template, func(t *testing.T) { + result, err := HasTemplateActions(tt.template) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} +``` + +This package can then be used by: +- **Locals resolver** - Extract `.locals.X` dependencies +- **Existing `IsGolangTemplate()`** - Could migrate to use `HasTemplateActions()` +- **Future features** - Template validation, dependency analysis, etc. + +--- + +### Files to Create + +#### `pkg/locals/resolver.go` - Core Locals Resolution + +```go +package locals + +import ( + "fmt" + "sort" + + atmostmpl "github.com/cloudposse/atmos/pkg/template" +) + +// LocalsResolver handles dependency resolution and cycle detection for locals. +type LocalsResolver struct { + locals map[string]any // Raw local definitions + resolved map[string]any // Resolved local values + dependencies map[string][]string // Dependency graph: local -> locals it depends on + filePath string // For error messages +} + +// NewLocalsResolver creates a resolver for a set of locals. +func NewLocalsResolver(locals map[string]any, filePath string) *LocalsResolver { + return &LocalsResolver{ + locals: locals, + resolved: make(map[string]any), + dependencies: make(map[string][]string), + filePath: filePath, + } +} + +// Resolve processes all locals in dependency order, returning resolved values. +// Returns error if circular dependency detected or undefined local referenced. +func (r *LocalsResolver) Resolve(parentLocals map[string]any) (map[string]any, error) { + // Step 1: Build dependency graph + if err := r.buildDependencyGraph(); err != nil { + return nil, err + } + + // Step 2: Topological sort with cycle detection + order, err := r.topologicalSort() + if err != nil { + return nil, err + } + + // Step 3: Resolve in order + // Start with parent locals (from outer scope) + for k, v := range parentLocals { + r.resolved[k] = v + } + + for _, name := range order { + value, err := r.resolveLocal(name) + if err != nil { + return nil, err + } + r.resolved[name] = value + } + + return r.resolved, nil +} + +// buildDependencyGraph extracts .locals.X references using the pkg/template AST utilities. +// This handles complex expressions like conditionals, pipes, range, and with blocks. +func (r *LocalsResolver) buildDependencyGraph() error { + for name, value := range r.locals { + var deps []string + + // Only string values can have template references + if strVal, ok := value.(string); ok { + // Use pkg/template AST utilities to extract .locals.X references + extracted, err := atmostmpl.ExtractFieldRefsByPrefix(strVal, "locals") + if err != nil { + // Not a valid template - no deps (will fail later during resolution) + r.dependencies[name] = deps + continue + } + deps = extracted + } + + r.dependencies[name] = deps + } + return nil +} + +// topologicalSort returns locals in resolution order, detecting cycles. +func (r *LocalsResolver) topologicalSort() ([]string, error) { + // Kahn's algorithm with cycle detection. + // inDegree[x] = number of locals that x depends on (within this scope). + inDegree := make(map[string]int) + for name, deps := range r.dependencies { + count := 0 + for _, dep := range deps { + if _, exists := r.locals[dep]; exists { + count++ + } + } + inDegree[name] = count + } + + // Start with nodes that have no dependencies + var queue []string + for name, degree := range inDegree { + if degree == 0 { + queue = append(queue, name) + } + } + sort.Strings(queue) // Deterministic order + + var result []string + for len(queue) > 0 { + // Pop from queue + name := queue[0] + queue = queue[1:] + result = append(result, name) + + // Reduce in-degree of dependents + for dependent, deps := range r.dependencies { + for _, dep := range deps { + if dep == name { + inDegree[dependent]-- + if inDegree[dependent] == 0 { + queue = append(queue, dependent) + sort.Strings(queue) + } + } + } + } + } + + // If not all nodes processed, there's a cycle + if len(result) != len(r.locals) { + cycle := r.findCycle() + return nil, fmt.Errorf("circular dependency in locals at %s\n\nDependency cycle detected:\n %s", + r.filePath, cycle) + } + + return result, nil +} + +// findCycle uses DFS to find and return a cycle for error reporting. +func (r *LocalsResolver) findCycle() string { + visited := make(map[string]bool) + recStack := make(map[string]bool) + var cyclePath []string + + var dfs func(name string) bool + dfs = func(name string) bool { + visited[name] = true + recStack[name] = true + cyclePath = append(cyclePath, name) + + for _, dep := range r.dependencies[name] { + if _, exists := r.locals[dep]; !exists { + continue // Skip parent scope locals + } + if !visited[dep] { + if dfs(dep) { + return true + } + } else if recStack[dep] { + // Found cycle - trim cyclePath to start at dep + for i, n := range cyclePath { + if n == dep { + cyclePath = append(cyclePath[i:], dep) + return true + } + } + } + } + + cyclePath = cyclePath[:len(cyclePath)-1] + recStack[name] = false + return false + } + + for name := range r.locals { + if !visited[name] { + if dfs(name) { + break + } + } + } + + // Format cycle as "a → b → c → a" + result := "" + for i, name := range cyclePath { + if i > 0 { + result += " → " + } + result += name + } + return result +} + +// resolveLocal resolves a single local's value using the template engine. +func (r *LocalsResolver) resolveLocal(name string) (any, error) { + value := r.locals[name] + + // Non-string values don't need template processing + strVal, ok := value.(string) + if !ok { + return value, nil + } + + // Use existing template processing with resolved locals as context + // This integrates with internal/exec/template_utils.go ProcessTmpl() + context := map[string]any{ + "locals": r.resolved, + } + + // Call existing template processor (implementation detail) + resolved, err := processTemplate(strVal, context) + if err != nil { + return nil, fmt.Errorf("failed to resolve local %q in %s: %w", name, r.filePath, err) + } + + return resolved, nil +} +``` + +### Files to Modify + +#### 1. `pkg/config/const.go` - Add Constant + +```go +// Add to existing constants +LocalsSectionName = "locals" +``` + +#### 2. `internal/exec/stack_processor_process_stacks_helpers.go` - Add to Processor Options + +```go +// Add to ComponentProcessorOptions struct +type ComponentProcessorOptions struct { + // ... existing fields ... + + // File-scoped locals (not inherited across imports) + GlobalLocals map[string]any // Resolved global locals from current file + TerraformLocals map[string]any // Resolved terraform-section locals + HelmfileLocals map[string]any // Resolved helmfile-section locals + ComponentLocals map[string]any // Resolved component-level locals +} +``` + +#### 3. `internal/exec/stack_processor_process_stacks.go` - Extract & Resolve Locals + +Integration point in `ProcessStackConfig()`: + +```go +func ProcessStackConfig( + atmosConfig *schema.AtmosConfiguration, + stacksBasePath string, + // ... other params +) (map[string]any, error) { + // ... existing code to load YAML ... + + // NEW: Extract and resolve file-scoped locals BEFORE processing other sections + globalLocals, err := extractAndResolveLocals(stackConfigMap, cfg.LocalsSectionName, filePath, nil) + if err != nil { + return nil, err + } + + // Extract terraform-section locals with global locals as parent + terraformLocals, err := extractAndResolveLocals( + stackConfigMap["terraform"], + cfg.LocalsSectionName, + filePath, + globalLocals, + ) + if err != nil { + return nil, err + } + + // ... continue with existing processing, passing locals to template context ... +} + +// extractAndResolveLocals extracts locals from a config section and resolves them. +func extractAndResolveLocals( + section any, + key string, + filePath string, + parentLocals map[string]any, +) (map[string]any, error) { + sectionMap, ok := section.(map[string]any) + if !ok { + return parentLocals, nil // No locals in this section + } + + localsRaw, exists := sectionMap[key] + if !exists { + return parentLocals, nil + } + + localsMap, ok := localsRaw.(map[string]any) + if !ok { + return nil, fmt.Errorf("locals must be a map in %s", filePath) + } + + resolver := locals.NewLocalsResolver(localsMap, filePath) + return resolver.Resolve(parentLocals) +} +``` + +#### 4. `internal/exec/stack_processor_process_stacks_helpers_extraction.go` - Component Locals + +Add extraction of component-level locals: + +```go +func extractComponentSections(component map[string]any, opts *ComponentProcessorOptions) error { + // ... existing extractions for vars, settings, env ... + + // NEW: Extract component-level locals + if localsSection, ok := component[cfg.LocalsSectionName]; ok { + if localsMap, ok := localsSection.(map[string]any); ok { + // Merge parent locals (global + terraform/helmfile) as context + parentLocals := mergeMaps(opts.GlobalLocals, opts.TerraformLocals) + + resolver := locals.NewLocalsResolver(localsMap, opts.FilePath) + resolvedLocals, err := resolver.Resolve(parentLocals) + if err != nil { + return err + } + opts.ComponentLocals = resolvedLocals + } + } + + return nil +} +``` + +#### 5. `internal/exec/template_utils.go` - Add Locals to Template Context + +When building template context, include resolved locals: + +```go +func buildTemplateContext(opts *ComponentProcessorOptions) map[string]any { + return map[string]any{ + "vars": opts.ComponentVars, + "settings": opts.ComponentSettings, + "env": opts.ComponentEnv, + // NEW: Merged locals from all scopes + "locals": mergeMaps(opts.GlobalLocals, opts.TerraformLocals, opts.ComponentLocals), + } +} +``` + +#### 6. `internal/exec/stack_processor_merge.go` - Exclude Locals from Merge + +Ensure locals are NOT merged across file boundaries: + +```go +func mergeStackConfigs(base, override map[string]any) map[string]any { + result := deepCopy(base) + + for key, value := range override { + // NEW: Skip locals - they are file-scoped only + if key == cfg.LocalsSectionName { + continue + } + + // ... existing merge logic ... + } + + return result +} +``` + +#### 7. `pkg/datafetcher/schema/stacks/stack-config/1.0.json` - Schema Updates + +Add `locals` to allowed sections: + +```json +{ + "properties": { + "locals": { + "type": "object", + "description": "File-scoped local variables for use in templates within this file", + "additionalProperties": true + }, + "terraform": { + "properties": { + "locals": { + "type": "object", + "description": "Terraform-scoped local variables", + "additionalProperties": true + } + } + }, + "components": { + "properties": { + "terraform": { + "additionalProperties": { + "properties": { + "locals": { + "type": "object", + "description": "Component-scoped local variables", + "additionalProperties": true + } + } + } + } + } + } + } +} +``` + +### Existing Code to Reuse + +| What | Where | How to Reuse | +|------|-------|--------------| +| Template processing | `internal/exec/template_utils.go` → `ProcessTmpl()` | Call directly for resolving local values | +| Cycle detection pattern | `internal/exec/yaml_func_resolution_context.go` | Reference for error message formatting | +| Deep map merge | `pkg/merge/merge.go` → `MergeWithDeferred()` | Merge parent + child locals | +| Section extraction | `stack_processor_process_stacks_helpers_extraction.go` | Follow same pattern for locals | +| Command registry pattern | `cmd/internal/registry.go` | Reference for any new describe subcommands | +| Flag handler | `pkg/flags/` | Use `StandardParser` for any new CLI flags | + +### Architecture Notes (Post-Terraform Refactoring) + +The codebase has been refactored with these patterns that locals implementation should follow: + +1. **Command Registry Pattern** (`cmd/internal/registry.go`): + - Commands implement `CommandProvider` interface + - Register via `internal.Register()` in `init()` + - If adding `atmos describe locals` command, follow this pattern + +2. **Flag Handling** (`pkg/flags/`): + - Use `flags.NewStandardParser()` with functional options + - Bind to Viper for env var support + - Example: `cmd/terraform/terraform.go` lines 42-58 + +3. **Package Structure**: + - Business logic in `pkg/` (e.g., `pkg/locals/`, `pkg/template/`) + - CLI wrappers in `cmd/` (thin, delegate to `pkg/`) + - Follow `cmd/terraform/` structure if adding commands + +4. **Component Resolution** (`pkg/component/resolver.go`): + - New resolver pattern for component path resolution + - Locals should integrate with this for component-scoped locals + +### Test Strategy + +```go +// pkg/locals/resolver_test.go + +func TestLocalsResolver_SimpleResolution(t *testing.T) { + locals := map[string]any{ + "a": "value-a", + "b": "{{ .locals.a }}-extended", + } + resolver := NewLocalsResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + assert.NoError(t, err) + assert.Equal(t, "value-a", result["a"]) + assert.Equal(t, "value-a-extended", result["b"]) +} + +func TestLocalsResolver_CycleDetection(t *testing.T) { + locals := map[string]any{ + "a": "{{ .locals.c }}", + "b": "{{ .locals.a }}", + "c": "{{ .locals.b }}", + } + resolver := NewLocalsResolver(locals, "test.yaml") + _, err := resolver.Resolve(nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") + assert.Contains(t, err.Error(), "a → b → c → a") // or similar cycle representation +} + +func TestLocalsResolver_ParentScopeAccess(t *testing.T) { + parentLocals := map[string]any{ + "global": "from-parent", + } + locals := map[string]any{ + "child": "{{ .locals.global }}-child", + } + resolver := NewLocalsResolver(locals, "test.yaml") + result, err := resolver.Resolve(parentLocals) + + assert.NoError(t, err) + assert.Equal(t, "from-parent-child", result["child"]) +} + +func TestLocalsResolver_OrderIndependent(t *testing.T) { + // Defined in reverse dependency order + locals := map[string]any{ + "c": "{{ .locals.b }}-c", + "b": "{{ .locals.a }}-b", + "a": "start", + } + resolver := NewLocalsResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + assert.NoError(t, err) + assert.Equal(t, "start", result["a"]) + assert.Equal(t, "start-b", result["b"]) + assert.Equal(t, "start-b-c", result["c"]) +} + +func TestLocalsResolver_ComplexTemplateExpressions(t *testing.T) { + tests := []struct { + name string + locals map[string]any + expectedDeps map[string][]string // local name -> expected dependencies + }{ + { + name: "conditional expression", + locals: map[string]any{ + "result": "{{ if .locals.flag }}{{ .locals.x }}{{ else }}{{ .locals.y }}{{ end }}", + }, + expectedDeps: map[string][]string{ + "result": {"flag", "x", "y"}, + }, + }, + { + name: "pipe with multiple refs", + locals: map[string]any{ + "result": `{{ .locals.foo | printf "%s-%s" .locals.bar }}`, + }, + expectedDeps: map[string][]string{ + "result": {"foo", "bar"}, + }, + }, + { + name: "range over local", + locals: map[string]any{ + "result": "{{ range .locals.items }}{{ .locals.prefix }}-{{ . }}{{ end }}", + }, + expectedDeps: map[string][]string{ + "result": {"items", "prefix"}, + }, + }, + { + name: "with block - dot changes context", + locals: map[string]any{ + // Inside with block, .name refers to .locals.config.name, NOT .locals.name + "result": "{{ with .locals.config }}{{ .name }}{{ end }}", + }, + expectedDeps: map[string][]string{ + "result": {"config"}, // Only config, not "name" + }, + }, + { + name: "nested conditionals", + locals: map[string]any{ + "result": "{{ if .locals.a }}{{ if .locals.b }}{{ .locals.c }}{{ end }}{{ end }}", + }, + expectedDeps: map[string][]string{ + "result": {"a", "b", "c"}, + }, + }, + { + name: "sprig function with local", + locals: map[string]any{ + "result": "{{ .locals.name | upper | quote }}", + }, + expectedDeps: map[string][]string{ + "result": {"name"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolver := NewLocalsResolver(tt.locals, "test.yaml") + err := resolver.buildDependencyGraph() + assert.NoError(t, err) + + for local, expectedDeps := range tt.expectedDeps { + actualDeps := resolver.dependencies[local] + // Sort both for comparison + sort.Strings(actualDeps) + sort.Strings(expectedDeps) + assert.Equal(t, expectedDeps, actualDeps, "deps mismatch for %s", local) + } + }) + } +} +``` + +### Implementation Phases + +**Phase 1: Template AST Package** (~1-2 days) +- Create `pkg/template/ast.go` with `ExtractFieldRefs()`, `ExtractFieldRefsByPrefix()`, `walkAST()` +- Create `pkg/template/ast_test.go` with comprehensive tests for complex expressions +- Add `HasTemplateActions()` as improved replacement for `IsGolangTemplate()` + +**Phase 2: Locals Resolver Package** (~2-3 days) +- Create `pkg/locals/resolver.go` using `pkg/template` for dependency extraction +- Implement topological sort with Kahn's algorithm +- Implement cycle detection with DFS for clear error messages +- Comprehensive unit tests for resolution, cycles, parent scope access + +**Phase 3: Stack Processor Integration** (~2-3 days) +- Add `LocalsSectionName` constant to `pkg/config/const.go` +- Modify `ComponentProcessorOptions` to carry locals at each scope +- Extract and resolve global locals in `ProcessStackConfig()` +- Extract and resolve component-type locals (terraform/helmfile sections) + +**Phase 4: Component-Level Integration** (~1-2 days) +- Extract component-level locals in `extractComponentSections()` +- Add `.locals` to template context in `buildTemplateContext()` +- Ensure locals excluded from merge operations (file-scoped only) + +**Phase 5: Schema & Validation** (~1 day) +- Update JSON schema to allow `locals` at all scopes +- Add validation for locals section structure + +**Phase 6: Integration Tests & Documentation** (~2 days) +- End-to-end tests with real stack files +- Test file-scoped isolation (imports don't leak locals) +- Test complex template expressions with locals +- Update documentation + +**Phase 7: Debugging Support** (~2-3 days) +- Implement `atmos describe locals` command with provenance support + - 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 + +**Optional: Migrate IsGolangTemplate** (~0.5 days) +- Update `internal/exec/template_utils.go` to use `pkg/template.HasTemplateActions()` +- Deprecate old implementation + +--- + +## Final Implementation Plan + +### Summary + +| Phase | Deliverable | Files | Effort | +|-------|-------------|-------|--------| +| 1 | Template AST Package | `pkg/template/ast.go`, `pkg/template/ast_test.go` | 1-2 days | +| 2 | Locals Resolver | `pkg/locals/resolver.go`, `pkg/locals/resolver_test.go` | 2-3 days | +| 3 | Stack Processor Integration | Modify 3 files in `internal/exec/` | 2-3 days | +| 4 | Component Integration | Modify 2 files in `internal/exec/` | 1-2 days | +| 5 | Schema & Validation | `pkg/datafetcher/schema/`, `pkg/config/const.go` | 1 day | +| 6 | Integration Tests & Docs | `tests/`, `website/docs/` | 2 days | +| 7 | `atmos describe locals` Command | `cmd/describe/locals.go`, `internal/exec/describe_locals.go` | 2-3 days | +| **Total** | | **~18 files** | **~12-16 days** | + +### Detailed Task Breakdown + +#### Phase 1: Template AST Package (Foundation) + +**Create `pkg/template/ast.go`:** +``` +pkg/template/ +├── ast.go # ExtractFieldRefs, ExtractFieldRefsByPrefix, walkAST, HasTemplateActions +└── ast_test.go # Table-driven tests for all complex template patterns +``` + +**Functions:** +- `ExtractFieldRefs(templateStr) → []FieldRef` - Parse template, walk AST, return all `.X.Y` refs +- `ExtractFieldRefsByPrefix(templateStr, prefix) → []string` - Filter refs by prefix (e.g., "locals") +- `walkAST(node, fn)` - Recursive AST walker handling all node types +- `HasTemplateActions(str) → bool` - Detect if string contains template actions + +**Test coverage:** +- Simple field refs: `{{ .locals.foo }}` +- Multiple refs: `{{ .locals.a }}-{{ .locals.b }}` +- Conditionals: `{{ if .locals.x }}...{{ end }}` +- Pipes: `{{ .locals.foo | upper }}` +- Range: `{{ range .locals.items }}...{{ end }}` +- With (context change): `{{ with .locals.config }}{{ .name }}{{ end }}` +- Nested structures +- Invalid templates (graceful handling) + +#### Phase 2: Locals Resolver Package + +**Create `pkg/locals/resolver.go`:** +``` +pkg/locals/ +├── resolver.go # LocalsResolver struct, Resolve, buildDependencyGraph, topologicalSort, findCycle +└── resolver_test.go # Unit tests +``` + +**LocalsResolver API:** +```go +resolver := NewLocalsResolver(localsMap, filePath) +resolved, err := resolver.Resolve(parentLocals) +``` + +**Algorithms:** +- **Dependency extraction**: Use `pkg/template.ExtractFieldRefsByPrefix(value, "locals")` +- **Topological sort**: Kahn's algorithm for resolution order +- **Cycle detection**: DFS with recursion stack for clear error paths + +**Test coverage:** +- Simple resolution (no deps) +- Chained locals (a → b → c) +- Order independence (c, b, a defined but resolved as a, b, c) +- Parent scope access (component refs global) +- Cycle detection with clear error messages +- Non-string values (pass through unchanged) +- Invalid template syntax (graceful handling) + +#### Phase 3: Stack Processor Integration + +**Modify `pkg/config/const.go`:** +```go +LocalsSectionName = "locals" +``` + +**Modify `internal/exec/stack_processor_process_stacks_helpers.go`:** +```go +type ComponentProcessorOptions struct { + // ... existing fields ... + GlobalLocals map[string]any + TerraformLocals map[string]any + HelmfileLocals map[string]any + ComponentLocals map[string]any +} +``` + +**Modify `internal/exec/stack_processor_process_stacks.go`:** +- Add `extractAndResolveLocals()` helper function +- Extract global locals early in `ProcessStackConfig()` +- Extract terraform/helmfile section locals with global as parent +- Pass locals through to component processing + +#### Phase 4: Component-Level Integration + +**Modify `internal/exec/stack_processor_process_stacks_helpers_extraction.go`:** +- Extract component-level locals in `extractComponentSections()` +- Merge parent scopes (global + terraform/helmfile) before resolving + +**Modify `internal/exec/template_utils.go` or equivalent:** +- Add `.locals` to template context alongside `.vars`, `.settings`, `.env` + +**Modify `internal/exec/stack_processor_merge.go`:** +- Skip `locals` key during merge (file-scoped only) + +#### Phase 5: Schema & Validation + +**Update `pkg/datafetcher/schema/stacks/stack-config/1.0.json`:** +- Add `locals` property at root level +- Add `locals` property to `terraform` section +- Add `locals` property to `helmfile` section +- Add `locals` property to `packer` section +- Add `locals` property to component definitions + +**Add validation:** +- Locals must be a map +- Key names must be valid identifiers + +#### Phase 6: Integration Tests & Documentation + +**Create test fixtures in `tests/test-cases/`:** +``` +tests/test-cases/locals/ +├── atmos.yaml +├── stacks/ +│ ├── _defaults.yaml # Global locals (should NOT inherit) +│ ├── catalog/ +│ │ └── vpc.yaml # Component with locals +│ └── deploy/ +│ └── prod.yaml # Import + own locals +└── components/ + └── terraform/ + └── vpc/ +``` + +**Test scenarios:** +1. Basic locals resolution +2. Locals referencing other locals (chained) +3. Scoped locals (global → terraform → component) +4. File isolation (import doesn't leak locals) +5. Cycle detection error +6. Undefined local error +7. Complex template expressions +8. YAML functions in locals (`!env`, `!template`) + +**Documentation:** +- Add to `website/docs/core-concepts/stacks/` or similar +- Add examples to existing stack configuration docs +- Update schema documentation + +### Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Performance impact | Locals resolved once per file, cached for all components | +| Complex template parsing | AST-based (not regex), handles all Go template constructs | +| Breaking existing configs | `locals` is a new section, no existing configs use it | +| Confusing error messages | Provide file path, line numbers, available locals, hints | + +### 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: + +#### The `atmos describe locals` Command + +```bash +# Show all locals for a component in a stack +atmos describe locals vpc -s prod-us-east-1 + +# Output as JSON +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 + +global: # Source: stacks/catalog/vpc.yaml:3 + region: "us-east-1" + account_id: "123456789012" + +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" + +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) +``` + +#### Key Features + +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 + +#### Optional: `ATMOS_DEBUG_LOCALS` Environment Variable + +For verbose logging during stack processing: + +```bash +ATMOS_DEBUG_LOCALS=true atmos terraform plan vpc -s prod-us-east-1 +``` + +Output (to stderr): +``` +[locals] Processing stacks/deploy/prod.yaml +[locals] Global scope: 2 locals defined +[locals] region = "us-east-1" +[locals] account_id = "123456789012" +[locals] Terraform scope: 1 local defined +[locals] state_bucket = "terraform-state-{{ .locals.account_id }}" +[locals] → resolved: "terraform-state-123456789012" +[locals] Component vpc scope: 1 local defined +[locals] vpc_name = "main-vpc-{{ .locals.region }}" +[locals] → resolved: "main-vpc-us-east-1" +[locals] Resolution complete: 4 locals available +``` + +#### Option 4: Provenance in Error Messages + +When a template fails, show which locals were available: + +``` +Error: template execution failed in stacks/deploy/prod.yaml + +Template: "{{ .locals.vpc_naem }}" # Typo! +Error: map has no entry for key "vpc_naem" + +Available locals at this scope: + - region: "us-east-1" + - account_id: "123456789012" + - state_bucket: "terraform-state-123456789012" + - vpc_name: "main-vpc-us-east-1" ← Did you mean this? + +Hint: Check for typos in local variable names. +``` + +#### Recommended Implementation + +| Feature | Priority | Effort | Phase | +|---------|----------|--------|-------| +| Provenance in error messages | 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 | + +**Minimum viable debugging (Phase 2):** +- Clear error messages showing available locals on failure +- 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. + +#### Analysis: Do Component Providers Need Locals? + +**No changes needed to the `ComponentProvider` interface.** Here's why: + +| Data Type | Passed to Provider? | Reason | +|-----------|---------------------|--------| +| `vars` | ✅ Yes | Passed to Terraform/Helmfile as inputs | +| `settings` | ✅ Yes | Component metadata for hooks, validation | +| `env` | ✅ Yes | Environment variables for subprocess | +| **`locals`** | ❌ **No** | Resolved during template processing, not needed at execution time | + +Locals are **consumed during stack processing** to generate the final `vars`, `settings`, and `env` values. By the time a component executes, all `.locals.*` references have been resolved to their final values. + +#### Execution Flow + +``` +Stack Processing Phase (where locals are used): +┌─────────────────────────────────────────────────────────────────┐ +│ 1. Parse YAML file │ +│ 2. Extract & resolve locals (global → terraform → component) │ +│ 3. Process templates in vars/settings/env with locals context │ +│ 4. Merge with inherited values │ +│ 5. Build ConfigAndStacksInfo with resolved values │ +└─────────────────────────────────────────────────────────────────┘ + ↓ + ConfigAndStacksInfo + (vars, settings, env - all resolved) + ↓ +Component Execution Phase (locals NOT needed): +┌─────────────────────────────────────────────────────────────────┐ +│ 6. ComponentProvider.Execute(ExecutionContext) │ +│ - ComponentConfig contains resolved vars │ +│ - No locals references remain (all resolved) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### What Changes ARE Needed + +**1. `ConfigAndStacksInfo` in `pkg/schema/schema.go`:** + +No new field needed for locals. The existing fields are sufficient: +- `ComponentVarsSection` - Contains resolved values (locals already applied) +- `ComponentSettingsSection` - Contains resolved values +- `ComponentEnvSection` - Contains resolved values + +**2. Stack Processor (where locals are actually used):** + +The changes are in `internal/exec/stack_processor_*.go`: +- Extract locals before processing templates +- Add `.locals` to template context alongside `.vars`, `.settings` +- Locals are consumed during template resolution, not stored in final output + +**3. ExecutionContext in `pkg/component/provider.go`:** + +No changes needed. The `ComponentConfig map[string]any` already contains the resolved vars. Locals have done their job during template processing. + +#### Why NOT Add ComponentLocalsSection? + +Adding `ComponentLocalsSection` to `ConfigAndStacksInfo` would be **incorrect** because: + +1. **File-scoped semantics**: Locals are explicitly file-scoped. Storing them in `ConfigAndStacksInfo` (which aggregates data across files) would violate this design. + +2. **Template-time only**: Locals exist only during template processing. Once templates are resolved, locals serve no purpose. + +3. **Not visible in describe**: Per the PRD, locals should NOT appear in `atmos describe component` output. Storing them in `ConfigAndStacksInfo` would require explicit exclusion logic. + +4. **Memory efficiency**: No need to carry locals through the execution pipeline when they're only needed during stack processing. + +#### Integration Points Summary + +| Location | Change Needed | Description | +|----------|---------------|-------------| +| `pkg/component/provider.go` | ❌ None | Interface unchanged | +| `pkg/component/resolver.go` | ❌ None | Path resolution unchanged | +| `pkg/schema/schema.go` (`ConfigAndStacksInfo`) | ❌ None | Locals don't need to be stored | +| `internal/exec/stack_processor_*.go` | ✅ Yes | Extract & resolve locals, add to template context | +| `internal/exec/template_utils.go` | ✅ Yes | Add `.locals` to template context | +| `pkg/locals/` (new) | ✅ Yes | New package for resolution logic | +| `pkg/template/` (new) | ✅ Yes | New package for AST utilities | + +#### Future Considerations + +If a future feature needs access to resolved locals at execution time (e.g., enhanced debugging, CI/CD integration), we could add: + +```go +// In schema.go - only if needed for advanced features +type ConfigAndStacksInfo struct { + // ...existing fields... + + // ComponentLocalsResolved stores the final resolved locals for debugging. + // This is NOT used during execution - locals are consumed during template processing. + // Only populated when ATMOS_DEBUG_LOCALS=true is set. + ComponentLocalsResolved AtmosSectionMapType `json:"-"` // Exclude from JSON output +} +``` + +But this is **not needed for the initial implementation**. The `atmos describe locals` command provides all necessary debugging capabilities. + +### Success Metrics + +1. **Functional**: All test scenarios pass +2. **Performance**: <10ms overhead per file for typical locals (5-20 entries) +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 diff --git a/errors/errors.go b/errors/errors.go index c2b8f798f1..30c5dd727f 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -658,6 +658,12 @@ var ( // Interactive prompt errors. ErrInteractiveModeNotAvailable = errors.New("interactive mode not available") ErrNoOptionsAvailable = errors.New("no options available") + + // Locals-related errors. + ErrLocalsInvalidType = errors.New("locals must be a map") + ErrLocalsCircularDep = errors.New("circular dependency in locals") + ErrLocalsDependencyExtract = errors.New("failed to extract dependencies for local") + ErrLocalsResolution = errors.New("failed to resolve local") ) // ExitCodeError is a typed error that preserves subcommand exit codes. diff --git a/examples/demo-context/schemas/atmos-manifest.json b/examples/demo-context/schemas/atmos-manifest.json index a60c3c1062..f6c5eb3b0f 100644 --- a/examples/demo-context/schemas/atmos-manifest.json +++ b/examples/demo-context/schemas/atmos-manifest.json @@ -28,6 +28,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "components": { "$ref": "#/definitions/components" }, @@ -164,7 +167,7 @@ }, { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "properties": { "terraform": { "$ref": "#/definitions/terraform_components" @@ -204,6 +207,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -276,6 +282,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -323,6 +332,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -380,6 +392,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -409,6 +424,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -466,6 +484,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -685,6 +706,20 @@ } ] }, + "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.", + "oneOf": [ + { + "type": "string", + "pattern": "^!include" + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, "backend_type": { "title": "backend_type", "description": "Backend type", @@ -916,9 +951,6 @@ { "type": "object", "properties": { - "stack": { - "type": "string" - }, "namespace": { "type": "string" }, diff --git a/examples/demo-helmfile/schemas/atmos-manifest.json b/examples/demo-helmfile/schemas/atmos-manifest.json index a60c3c1062..f6c5eb3b0f 100644 --- a/examples/demo-helmfile/schemas/atmos-manifest.json +++ b/examples/demo-helmfile/schemas/atmos-manifest.json @@ -28,6 +28,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "components": { "$ref": "#/definitions/components" }, @@ -164,7 +167,7 @@ }, { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "properties": { "terraform": { "$ref": "#/definitions/terraform_components" @@ -204,6 +207,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -276,6 +282,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -323,6 +332,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -380,6 +392,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -409,6 +424,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -466,6 +484,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -685,6 +706,20 @@ } ] }, + "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.", + "oneOf": [ + { + "type": "string", + "pattern": "^!include" + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, "backend_type": { "title": "backend_type", "description": "Backend type", @@ -916,9 +951,6 @@ { "type": "object", "properties": { - "stack": { - "type": "string" - }, "namespace": { "type": "string" }, diff --git a/examples/demo-localstack/schemas/atmos-manifest.json b/examples/demo-localstack/schemas/atmos-manifest.json index a60c3c1062..f6c5eb3b0f 100644 --- a/examples/demo-localstack/schemas/atmos-manifest.json +++ b/examples/demo-localstack/schemas/atmos-manifest.json @@ -28,6 +28,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "components": { "$ref": "#/definitions/components" }, @@ -164,7 +167,7 @@ }, { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "properties": { "terraform": { "$ref": "#/definitions/terraform_components" @@ -204,6 +207,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -276,6 +282,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -323,6 +332,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -380,6 +392,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -409,6 +424,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -466,6 +484,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -685,6 +706,20 @@ } ] }, + "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.", + "oneOf": [ + { + "type": "string", + "pattern": "^!include" + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, "backend_type": { "title": "backend_type", "description": "Backend type", @@ -916,9 +951,6 @@ { "type": "object", "properties": { - "stack": { - "type": "string" - }, "namespace": { "type": "string" }, diff --git a/internal/exec/stack_processor_locals.go b/internal/exec/stack_processor_locals.go new file mode 100644 index 0000000000..c4e6996355 --- /dev/null +++ b/internal/exec/stack_processor_locals.go @@ -0,0 +1,210 @@ +package exec + +import ( + "fmt" + + errUtils "github.com/cloudposse/atmos/errors" + cfg "github.com/cloudposse/atmos/pkg/config" + "github.com/cloudposse/atmos/pkg/locals" + "github.com/cloudposse/atmos/pkg/perf" + "github.com/cloudposse/atmos/pkg/schema" +) + +// ExtractAndResolveLocals extracts and resolves locals from a section. +// Returns resolved locals map (merged with parent locals) or nil if no locals section. +// If there's an error resolving locals, it returns the error. +func ExtractAndResolveLocals( + atmosConfig *schema.AtmosConfiguration, + section map[string]any, + parentLocals map[string]any, + filePath string, +) (map[string]any, error) { + defer perf.Track(atmosConfig, "exec.ExtractAndResolveLocals")() + + if section == nil { + return copyParentLocals(parentLocals), nil + } + + // Check for locals section. + localsRaw, exists := section[cfg.LocalsSectionName] + if !exists { + return copyParentLocals(parentLocals), nil + } + + // Locals must be a map. + localsMap, ok := localsRaw.(map[string]any) + if !ok { + return nil, fmt.Errorf("%w in %s", errUtils.ErrLocalsInvalidType, filePath) + } + + // Handle empty locals section. + if len(localsMap) == 0 { + return copyOrCreateParentLocals(parentLocals), nil + } + + // Resolve locals with dependency ordering and cycle detection. + return resolveLocalsWithDependencies(localsMap, parentLocals, filePath) +} + +// copyParentLocals creates a copy of parent locals or returns nil if no parent locals. +func copyParentLocals(parentLocals map[string]any) map[string]any { + if parentLocals == nil { + return nil + } + result := make(map[string]any, len(parentLocals)) + for k, v := range parentLocals { + result[k] = v + } + return result +} + +// copyOrCreateParentLocals creates a copy of parent locals or an empty map if nil. +func copyOrCreateParentLocals(parentLocals map[string]any) map[string]any { + if parentLocals == nil { + return make(map[string]any) + } + result := make(map[string]any, len(parentLocals)) + for k, v := range parentLocals { + result[k] = v + } + return result +} + +// resolveLocalsWithDependencies resolves locals using dependency ordering and cycle detection. +func resolveLocalsWithDependencies(localsMap, parentLocals map[string]any, filePath string) (map[string]any, error) { + resolver := locals.NewResolver(localsMap, filePath) + resolved, err := resolver.Resolve(parentLocals) + if err != nil { + return nil, err + } + return resolved, nil +} + +// 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, + filePath string, +) (*LocalsContext, error) { + defer perf.Track(atmosConfig, "exec.ProcessStackLocals")() + + ctx := &LocalsContext{} + + // Extract global locals (available to all sections). + globalLocals, err := ExtractAndResolveLocals(atmosConfig, stackConfigMap, nil, filePath) + if err != nil { + return nil, fmt.Errorf("failed to resolve global locals: %w", err) + } + ctx.Global = globalLocals + + // Extract terraform section locals (inherit from global). + 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 + } else { + ctx.Terraform = ctx.Global + } + + // Extract helmfile section locals (inherit from global). + if helmfileSection, ok := stackConfigMap[cfg.HelmfileSectionName].(map[string]any); ok { + helmfileLocals, err := ExtractAndResolveLocals(atmosConfig, helmfileSection, ctx.Global, filePath) + if err != nil { + return nil, fmt.Errorf("failed to resolve helmfile locals: %w", err) + } + ctx.Helmfile = helmfileLocals + } else { + ctx.Helmfile = ctx.Global + } + + // Extract packer section locals (inherit from global). + if packerSection, ok := stackConfigMap[cfg.PackerSectionName].(map[string]any); ok { + packerLocals, err := ExtractAndResolveLocals(atmosConfig, packerSection, ctx.Global, filePath) + if err != nil { + return nil, fmt.Errorf("failed to resolve packer locals: %w", err) + } + ctx.Packer = packerLocals + } else { + ctx.Packer = ctx.Global + } + + return ctx, nil +} + +// LocalsContext holds resolved locals at different scopes within a stack file. +// This is used to pass locals context during template processing. +type LocalsContext struct { + // Global holds locals defined at the stack file root level. + Global map[string]any + + // Terraform holds locals from the terraform section (merged with global). + Terraform map[string]any + + // Helmfile holds locals from the helmfile section (merged with global). + Helmfile map[string]any + + // Packer holds locals from the packer section (merged with global). + Packer map[string]any +} + +// 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")() + + if ctx == nil { + return nil + } + + switch componentType { + case cfg.TerraformSectionName: + return ctx.Terraform + case cfg.HelmfileSectionName: + return ctx.Helmfile + case cfg.PackerSectionName: + return ctx.Packer + default: + return ctx.Global + } +} + +// ResolveComponentLocals resolves locals for a specific component. +// It merges component-level locals with the parent scope (component-type or global). +func ResolveComponentLocals( + atmosConfig *schema.AtmosConfiguration, + componentConfig map[string]any, + parentLocals map[string]any, + filePath string, +) (map[string]any, error) { + defer perf.Track(atmosConfig, "exec.ResolveComponentLocals")() + + return ExtractAndResolveLocals(atmosConfig, componentConfig, parentLocals, filePath) +} + +// StripLocalsFromSection removes the locals section from a map. +// This is used to prevent locals from being merged across file boundaries +// and from appearing in the final component output. +func StripLocalsFromSection(section map[string]any) map[string]any { + defer perf.Track(nil, "exec.StripLocalsFromSection")() + + if section == nil { + return nil + } + + // If no locals section, return as-is. + if _, exists := section[cfg.LocalsSectionName]; !exists { + return section + } + + // Create a copy without locals. + result := make(map[string]any, len(section)-1) + for k, v := range section { + if k != cfg.LocalsSectionName { + result[k] = v + } + } + return result +} diff --git a/internal/exec/stack_processor_locals_test.go b/internal/exec/stack_processor_locals_test.go new file mode 100644 index 0000000000..84c5a9f7b5 --- /dev/null +++ b/internal/exec/stack_processor_locals_test.go @@ -0,0 +1,531 @@ +package exec + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cfg "github.com/cloudposse/atmos/pkg/config" + "github.com/cloudposse/atmos/pkg/schema" +) + +func TestExtractAndResolveLocals_Basic(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + section := map[string]any{ + "locals": map[string]any{ + "name": "myapp", + "env": "prod", + "combined": "{{ .locals.name }}-{{ .locals.env }}", + }, + } + + result, err := ExtractAndResolveLocals(atmosConfig, section, nil, "test.yaml") + + require.NoError(t, err) + assert.Equal(t, "myapp", result["name"]) + assert.Equal(t, "prod", result["env"]) + assert.Equal(t, "myapp-prod", result["combined"]) +} + +func TestExtractAndResolveLocals_NoLocalsSection(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + section := map[string]any{ + "vars": map[string]any{ + "foo": "bar", + }, + } + + result, err := ExtractAndResolveLocals(atmosConfig, section, nil, "test.yaml") + + require.NoError(t, err) + assert.Nil(t, result) +} + +func TestExtractAndResolveLocals_EmptyLocals(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + section := map[string]any{ + "locals": map[string]any{}, + } + + result, err := ExtractAndResolveLocals(atmosConfig, section, nil, "test.yaml") + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Empty(t, result) +} + +func TestExtractAndResolveLocals_WithParentLocals(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + parentLocals := map[string]any{ + "global": "parent-value", + } + section := map[string]any{ + "locals": map[string]any{ + "child": "{{ .locals.global }}-child", + }, + } + + result, err := ExtractAndResolveLocals(atmosConfig, section, parentLocals, "test.yaml") + + require.NoError(t, err) + assert.Equal(t, "parent-value", result["global"]) + assert.Equal(t, "parent-value-child", result["child"]) +} + +func TestExtractAndResolveLocals_NoSectionWithParent(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + parentLocals := map[string]any{ + "parent": "value", + } + + result, err := ExtractAndResolveLocals(atmosConfig, nil, parentLocals, "test.yaml") + + require.NoError(t, err) + assert.Equal(t, "value", result["parent"]) +} + +func TestExtractAndResolveLocals_InvalidLocalsType(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + section := map[string]any{ + "locals": "not a map", + } + + _, err := ExtractAndResolveLocals(atmosConfig, section, nil, "test.yaml") + + require.Error(t, err) + assert.Contains(t, err.Error(), "locals must be a map") +} + +func TestExtractAndResolveLocals_CycleDetection(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + section := map[string]any{ + "locals": map[string]any{ + "a": "{{ .locals.b }}", + "b": "{{ .locals.a }}", + }, + } + + _, err := ExtractAndResolveLocals(atmosConfig, section, nil, "test.yaml") + + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") +} + +func TestProcessStackLocals_AllScopes(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "locals": map[string]any{ + "global_var": "global-value", + }, + "terraform": map[string]any{ + "locals": map[string]any{ + "tf_var": "{{ .locals.global_var }}-terraform", + }, + }, + "helmfile": map[string]any{ + "locals": map[string]any{ + "hf_var": "{{ .locals.global_var }}-helmfile", + }, + }, + "packer": map[string]any{ + "locals": map[string]any{ + "pk_var": "{{ .locals.global_var }}-packer", + }, + }, + } + + ctx, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, ctx) + + // Global locals. + assert.Equal(t, "global-value", ctx.Global["global_var"]) + + // Terraform locals (merged with global). + assert.Equal(t, "global-value", ctx.Terraform["global_var"]) + assert.Equal(t, "global-value-terraform", ctx.Terraform["tf_var"]) + + // Helmfile locals (merged with global). + assert.Equal(t, "global-value", ctx.Helmfile["global_var"]) + assert.Equal(t, "global-value-helmfile", ctx.Helmfile["hf_var"]) + + // Packer locals (merged with global). + assert.Equal(t, "global-value", ctx.Packer["global_var"]) + assert.Equal(t, "global-value-packer", ctx.Packer["pk_var"]) +} + +func TestProcessStackLocals_NoLocals(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "vars": map[string]any{ + "foo": "bar", + }, + } + + ctx, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, ctx) + assert.Nil(t, ctx.Global) + assert.Nil(t, ctx.Terraform) + assert.Nil(t, ctx.Helmfile) + assert.Nil(t, ctx.Packer) +} + +func TestLocalsContext_GetForComponentType(t *testing.T) { + ctx := &LocalsContext{ + Global: map[string]any{"scope": "global"}, + Terraform: map[string]any{"scope": "terraform"}, + Helmfile: map[string]any{"scope": "helmfile"}, + Packer: map[string]any{"scope": "packer"}, + } + + assert.Equal(t, "terraform", ctx.GetForComponentType(cfg.TerraformSectionName)["scope"]) + assert.Equal(t, "helmfile", ctx.GetForComponentType(cfg.HelmfileSectionName)["scope"]) + assert.Equal(t, "packer", ctx.GetForComponentType(cfg.PackerSectionName)["scope"]) + assert.Equal(t, "global", ctx.GetForComponentType("unknown")["scope"]) +} + +func TestLocalsContext_GetForComponentType_Nil(t *testing.T) { + var ctx *LocalsContext + assert.Nil(t, ctx.GetForComponentType(cfg.TerraformSectionName)) +} + +func TestResolveComponentLocals(t *testing.T) { + atmosConfig := &schema.AtmosConfiguration{} + parentLocals := map[string]any{ + "region": "us-east-1", + } + componentConfig := map[string]any{ + "locals": map[string]any{ + "name": "my-component-{{ .locals.region }}", + }, + "vars": map[string]any{ + "foo": "bar", + }, + } + + result, err := ResolveComponentLocals(atmosConfig, componentConfig, parentLocals, "test.yaml") + + require.NoError(t, err) + assert.Equal(t, "us-east-1", result["region"]) + assert.Equal(t, "my-component-us-east-1", result["name"]) +} + +func TestStripLocalsFromSection(t *testing.T) { + section := map[string]any{ + "locals": map[string]any{ + "foo": "bar", + }, + "vars": map[string]any{ + "key": "value", + }, + } + + result := StripLocalsFromSection(section) + + assert.NotContains(t, result, "locals") + assert.Contains(t, result, "vars") + assert.Equal(t, map[string]any{"key": "value"}, result["vars"]) +} + +func TestStripLocalsFromSection_NoLocals(t *testing.T) { + section := map[string]any{ + "vars": map[string]any{ + "key": "value", + }, + } + + result := StripLocalsFromSection(section) + + // Should return the same map. + assert.Equal(t, section, result) +} + +func TestStripLocalsFromSection_Nil(t *testing.T) { + result := StripLocalsFromSection(nil) + assert.Nil(t, result) +} + +func TestExtractAndResolveLocals_EmptyLocalsWithParent(t *testing.T) { + // Test that empty locals section with parent returns parent locals copy. + atmosConfig := &schema.AtmosConfiguration{} + parentLocals := map[string]any{ + "parent_key": "parent_value", + } + section := map[string]any{ + "locals": map[string]any{}, + } + + result, err := ExtractAndResolveLocals(atmosConfig, section, parentLocals, "test.yaml") + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "parent_value", result["parent_key"]) +} + +func TestExtractAndResolveLocals_NilSection(t *testing.T) { + // Test with nil section. + atmosConfig := &schema.AtmosConfiguration{} + + result, err := ExtractAndResolveLocals(atmosConfig, nil, nil, "test.yaml") + + require.NoError(t, err) + assert.Nil(t, result) +} + +func TestProcessStackLocals_GlobalError(t *testing.T) { + // Test error handling when global locals have a cycle. + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "locals": map[string]any{ + "a": "{{ .locals.b }}", + "b": "{{ .locals.a }}", + }, + } + + _, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to resolve global locals") +} + +func TestProcessStackLocals_TerraformError(t *testing.T) { + // Test error handling when terraform locals have a cycle. + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "locals": map[string]any{ + "global_var": "value", + }, + "terraform": map[string]any{ + "locals": map[string]any{ + "a": "{{ .locals.b }}", + "b": "{{ .locals.a }}", + }, + }, + } + + _, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to resolve terraform locals") +} + +func TestProcessStackLocals_HelmfileError(t *testing.T) { + // Test error handling when helmfile locals have a cycle. + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "locals": map[string]any{ + "global_var": "value", + }, + "helmfile": map[string]any{ + "locals": map[string]any{ + "a": "{{ .locals.b }}", + "b": "{{ .locals.a }}", + }, + }, + } + + _, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to resolve helmfile locals") +} + +func TestProcessStackLocals_PackerError(t *testing.T) { + // Test error handling when packer locals have a cycle. + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "locals": map[string]any{ + "global_var": "value", + }, + "packer": map[string]any{ + "locals": map[string]any{ + "a": "{{ .locals.b }}", + "b": "{{ .locals.a }}", + }, + }, + } + + _, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to resolve packer locals") +} + +func TestProcessStackLocals_OnlyTerraformSection(t *testing.T) { + // Test with only terraform section, no helmfile or packer. + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "locals": map[string]any{ + "global_var": "global-value", + }, + "terraform": map[string]any{ + "locals": map[string]any{ + "tf_var": "{{ .locals.global_var }}-terraform", + }, + }, + } + + ctx, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, ctx) + + // Global locals. + assert.Equal(t, "global-value", ctx.Global["global_var"]) + + // Terraform locals. + assert.Equal(t, "global-value-terraform", ctx.Terraform["tf_var"]) + + // Helmfile and Packer should inherit from global. + assert.Equal(t, ctx.Global, ctx.Helmfile) + assert.Equal(t, ctx.Global, ctx.Packer) +} + +func TestProcessStackLocals_OnlyHelmfileSection(t *testing.T) { + // Test with only helmfile section. + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "locals": map[string]any{ + "global_var": "global-value", + }, + "helmfile": map[string]any{ + "locals": map[string]any{ + "hf_var": "{{ .locals.global_var }}-helmfile", + }, + }, + } + + ctx, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, ctx) + + // Helmfile locals. + assert.Equal(t, "global-value-helmfile", ctx.Helmfile["hf_var"]) + + // Terraform and Packer should inherit from global. + assert.Equal(t, ctx.Global, ctx.Terraform) + assert.Equal(t, ctx.Global, ctx.Packer) +} + +func TestProcessStackLocals_OnlyPackerSection(t *testing.T) { + // Test with only packer section. + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "locals": map[string]any{ + "global_var": "global-value", + }, + "packer": map[string]any{ + "locals": map[string]any{ + "pk_var": "{{ .locals.global_var }}-packer", + }, + }, + } + + ctx, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, ctx) + + // Packer locals. + assert.Equal(t, "global-value-packer", ctx.Packer["pk_var"]) + + // Terraform and Helmfile should inherit from global. + assert.Equal(t, ctx.Global, ctx.Terraform) + assert.Equal(t, ctx.Global, ctx.Helmfile) +} + +func TestProcessStackLocals_NonMapSections(t *testing.T) { + // Test with non-map sections (should be ignored). + atmosConfig := &schema.AtmosConfiguration{} + stackConfig := map[string]any{ + "locals": map[string]any{ + "global_var": "global-value", + }, + "terraform": "not a map", + "helmfile": 123, + "packer": []string{"not", "a", "map"}, + } + + ctx, err := ProcessStackLocals(atmosConfig, stackConfig, "test.yaml") + + require.NoError(t, err) + require.NotNil(t, ctx) + + // All should inherit from global since sections are not maps. + assert.Equal(t, ctx.Global, ctx.Terraform) + assert.Equal(t, ctx.Global, ctx.Helmfile) + assert.Equal(t, ctx.Global, ctx.Packer) +} + +func TestResolveComponentLocals_NoLocalsSection(t *testing.T) { + // Test component without locals section. + atmosConfig := &schema.AtmosConfiguration{} + parentLocals := map[string]any{ + "parent": "value", + } + componentConfig := map[string]any{ + "vars": map[string]any{ + "foo": "bar", + }, + } + + result, err := ResolveComponentLocals(atmosConfig, componentConfig, parentLocals, "test.yaml") + + require.NoError(t, err) + assert.Equal(t, "value", result["parent"]) +} + +func TestResolveComponentLocals_Error(t *testing.T) { + // Test component locals with cycle. + atmosConfig := &schema.AtmosConfiguration{} + componentConfig := map[string]any{ + "locals": map[string]any{ + "a": "{{ .locals.b }}", + "b": "{{ .locals.a }}", + }, + } + + _, err := ResolveComponentLocals(atmosConfig, componentConfig, nil, "test.yaml") + + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") +} + +func TestCopyParentLocals_EmptyMap(t *testing.T) { + // Test with empty parent locals map. + result := copyParentLocals(map[string]any{}) + assert.NotNil(t, result) + assert.Empty(t, result) +} + +func TestCopyOrCreateParentLocals_EmptyMap(t *testing.T) { + // Test with empty parent locals map. + result := copyOrCreateParentLocals(map[string]any{}) + assert.NotNil(t, result) + assert.Empty(t, result) +} + +func TestCopyOrCreateParentLocals_Nil(t *testing.T) { + // Test with nil parent locals. + result := copyOrCreateParentLocals(nil) + assert.NotNil(t, result) + assert.Empty(t, result) +} + +func TestCopyOrCreateParentLocals_WithData(t *testing.T) { + // Test with data. + parentLocals := map[string]any{ + "key1": "value1", + "key2": "value2", + } + result := copyOrCreateParentLocals(parentLocals) + assert.Equal(t, parentLocals, result) + // Verify it's a copy. + result["key1"] = "modified" + assert.Equal(t, "value1", parentLocals["key1"]) +} diff --git a/pkg/config/const.go b/pkg/config/const.go index 384d3a8538..bec90eb382 100644 --- a/pkg/config/const.go +++ b/pkg/config/const.go @@ -68,6 +68,7 @@ const ( HooksSectionName = "hooks" VarsSectionName = "vars" SettingsSectionName = "settings" + LocalsSectionName = "locals" EnvSectionName = "env" BackendSectionName = "backend" BackendTypeSectionName = "backend_type" diff --git a/pkg/datafetcher/schema/atmos/manifest/1.0.json b/pkg/datafetcher/schema/atmos/manifest/1.0.json index 3ffdbe7b7c..f6c5eb3b0f 100644 --- a/pkg/datafetcher/schema/atmos/manifest/1.0.json +++ b/pkg/datafetcher/schema/atmos/manifest/1.0.json @@ -28,6 +28,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "components": { "$ref": "#/definitions/components" }, @@ -204,6 +207,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -276,6 +282,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -323,6 +332,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -380,6 +392,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -409,6 +424,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -466,6 +484,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -685,6 +706,20 @@ } ] }, + "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.", + "oneOf": [ + { + "type": "string", + "pattern": "^!include" + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, "backend_type": { "title": "backend_type", "description": "Backend type", diff --git a/pkg/locals/resolver.go b/pkg/locals/resolver.go new file mode 100644 index 0000000000..70e56d05c8 --- /dev/null +++ b/pkg/locals/resolver.go @@ -0,0 +1,454 @@ +// Package locals provides resolution for file-scoped local variables in Atmos stack configurations. +// Locals enable users to define temporary variables that are available within a single file, +// similar to Terraform and Terragrunt locals. +// +// Key features: +// - File-scoped: locals do not inherit across file boundaries +// - Dependency resolution: locals can reference other locals with topological sorting +// - Cycle detection: circular dependencies are detected and reported clearly +// - Multi-scope: locals can be defined at global, component-type, and component levels +package locals + +import ( + "bytes" + "fmt" + "sort" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" + + errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/perf" + atmostmpl "github.com/cloudposse/atmos/pkg/template" +) + +// Resolver handles dependency resolution and cycle detection for locals. +// It uses topological sorting to determine the order in which locals should be resolved. +type Resolver struct { + locals map[string]any // Raw local definitions. + resolved map[string]any // Resolved local values. + dependencies map[string][]string // Dependency graph: local -> locals it depends on. + filePath string // Source file path for error messages. +} + +// NewResolver creates a resolver for a set of locals. +// The filePath is used for error message context. +func NewResolver(locals map[string]any, filePath string) *Resolver { + defer perf.Track(nil, "locals.NewResolver")() + + return &Resolver{ + locals: locals, + resolved: make(map[string]any), + dependencies: make(map[string][]string), + filePath: filePath, + } +} + +// Resolve processes all locals in dependency order, returning resolved values. +// Parent locals (from outer scopes) are available during resolution. +// Returns error if circular dependency detected or undefined local referenced. +func (r *Resolver) Resolve(parentLocals map[string]any) (map[string]any, error) { + defer perf.Track(nil, "locals.Resolver.Resolve")() + + // Handle nil/empty cases. + if len(r.locals) == 0 { + // Return copy of parent locals or empty map. + if parentLocals == nil { + return make(map[string]any), nil + } + result := make(map[string]any, len(parentLocals)) + for k, v := range parentLocals { + result[k] = v + } + return result, nil + } + + // Step 1: Build dependency graph. + if err := r.buildDependencyGraph(); err != nil { + return nil, err + } + + // Step 2: Topological sort with cycle detection. + order, err := r.topologicalSort() + if err != nil { + return nil, err + } + + // Step 3: Start with parent locals (from outer scope). + for k, v := range parentLocals { + r.resolved[k] = v + } + + // Step 4: Resolve in sorted order. + for _, name := range order { + value, err := r.resolveLocal(name) + if err != nil { + return nil, err + } + r.resolved[name] = value + } + + return r.resolved, nil +} + +// buildDependencyGraph extracts .locals.X references using the pkg/template AST utilities. +// This handles complex expressions like conditionals, pipes, range, and with blocks. +func (r *Resolver) buildDependencyGraph() error { + defer perf.Track(nil, "locals.Resolver.buildDependencyGraph")() + + for name, value := range r.locals { + deps, err := r.extractDependencies(value) + if err != nil { + return fmt.Errorf("%w %q in %s: %w", errUtils.ErrLocalsDependencyExtract, name, r.filePath, err) + } + r.dependencies[name] = deps + } + return nil +} + +// extractDependencies extracts .locals.X references from a value. +// Handles string values with Go templates and recursively processes maps and slices. +func (r *Resolver) extractDependencies(value any) ([]string, error) { + switch v := value.(type) { + case string: + return r.extractDepsFromString(v) + case map[string]any: + return r.extractDepsFromMap(v) + case []any: + return r.extractDepsFromSlice(v) + default: + // Non-string/map/slice values have no dependencies. + return nil, nil + } +} + +// extractDepsFromString extracts .locals.X references from a string template. +func (r *Resolver) extractDepsFromString(str string) ([]string, error) { + // Use pkg/template AST utilities to extract .locals.X references. + // If it's not a valid template, we return no dependencies. + // The actual template error will be caught during resolution. + deps, _ := atmostmpl.ExtractFieldRefsByPrefix(str, "locals") + return deps, nil +} + +// extractDepsFromMap extracts dependencies from map values. +func (r *Resolver) extractDepsFromMap(m map[string]any) ([]string, error) { + var allDeps []string + seen := make(map[string]bool) + for _, mapVal := range m { + deps, err := r.extractDependencies(mapVal) + if err != nil { + return nil, err + } + allDeps = r.collectUniqueDeps(allDeps, deps, seen) + } + return allDeps, nil +} + +// extractDepsFromSlice extracts dependencies from slice elements. +func (r *Resolver) extractDepsFromSlice(slice []any) ([]string, error) { + var allDeps []string + seen := make(map[string]bool) + for _, elem := range slice { + deps, err := r.extractDependencies(elem) + if err != nil { + return nil, err + } + allDeps = r.collectUniqueDeps(allDeps, deps, seen) + } + return allDeps, nil +} + +// collectUniqueDeps adds new dependencies to the list, avoiding duplicates. +func (r *Resolver) collectUniqueDeps(allDeps, newDeps []string, seen map[string]bool) []string { + for _, dep := range newDeps { + if !seen[dep] { + allDeps = append(allDeps, dep) + seen[dep] = true + } + } + return allDeps +} + +// topologicalSort returns locals in resolution order, detecting cycles. +// Uses Kahn's algorithm for topological sorting. +func (r *Resolver) topologicalSort() ([]string, error) { + // Calculate in-degree for each local (number of dependencies within this scope). + inDegree := r.calculateInDegree() + + // Start with nodes that have no dependencies within this scope. + queue := r.getZeroDegreeNodes(inDegree) + + // Process nodes in dependency order. + result := r.processTopologicalOrder(queue, inDegree) + + // If not all nodes processed, there's a cycle. + if len(result) != len(r.locals) { + cycle := r.findCycle() + return nil, fmt.Errorf("%w at %s\n\nDependency cycle detected:\n %s", + errUtils.ErrLocalsCircularDep, r.filePath, cycle) + } + + return result, nil +} + +// calculateInDegree computes the in-degree (number of dependencies) for each local. +func (r *Resolver) calculateInDegree() map[string]int { + inDegree := make(map[string]int) + for name, deps := range r.dependencies { + count := 0 + for _, dep := range deps { + // Only count dependencies within this scope. + if _, exists := r.locals[dep]; exists { + count++ + } + } + inDegree[name] = count + } + return inDegree +} + +// getZeroDegreeNodes returns nodes with no dependencies (in-degree zero). +func (r *Resolver) getZeroDegreeNodes(inDegree map[string]int) []string { + var queue []string + for name, degree := range inDegree { + if degree == 0 { + queue = append(queue, name) + } + } + sort.Strings(queue) // Deterministic order. + return queue +} + +// processTopologicalOrder processes nodes in topological order using Kahn's algorithm. +func (r *Resolver) processTopologicalOrder(queue []string, inDegree map[string]int) []string { + var result []string + for len(queue) > 0 { + // Pop from queue. + name := queue[0] + queue = queue[1:] + result = append(result, name) + + // Reduce in-degree of dependents. + queue = r.updateDependents(name, queue, inDegree) + } + return result +} + +// updateDependents reduces in-degree of nodes that depend on the given node. +func (r *Resolver) updateDependents(name string, queue []string, inDegree map[string]int) []string { + // Collect all newly available nodes first, then sort once. + var newlyAvailable []string + for dependent, deps := range r.dependencies { + for _, dep := range deps { + if dep == name { + inDegree[dependent]-- + if inDegree[dependent] == 0 { + newlyAvailable = append(newlyAvailable, dependent) + } + } + } + } + if len(newlyAvailable) > 0 { + queue = append(queue, newlyAvailable...) + sort.Strings(queue) // Sort once after all additions. + } + return queue +} + +// findCycle uses DFS to find and return a cycle for error reporting. +func (r *Resolver) findCycle() string { + visited := make(map[string]bool) + recStack := make(map[string]bool) + var cyclePath []string + + // Start DFS from each unvisited node. + names := r.getSortedLocalNames() + for _, name := range names { + if !visited[name] { + if r.dfsFindCycle(name, visited, recStack, &cyclePath) { + break + } + } + } + + return r.formatCyclePath(cyclePath) +} + +// getSortedLocalNames returns sorted list of local names for deterministic processing. +func (r *Resolver) getSortedLocalNames() []string { + names := make([]string, 0, len(r.locals)) + for name := range r.locals { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// dfsFindCycle performs depth-first search to find a cycle. +func (r *Resolver) dfsFindCycle(name string, visited, recStack map[string]bool, cyclePath *[]string) bool { + visited[name] = true + recStack[name] = true + *cyclePath = append(*cyclePath, name) + + for _, dep := range r.dependencies[name] { + if r.shouldSkipDependency(dep) { + continue + } + if r.checkDependencyForCycle(dep, visited, recStack, cyclePath) { + return true + } + } + + *cyclePath = (*cyclePath)[:len(*cyclePath)-1] + recStack[name] = false + return false +} + +// shouldSkipDependency checks if a dependency should be skipped (not in current scope). +func (r *Resolver) shouldSkipDependency(dep string) bool { + _, exists := r.locals[dep] + return !exists +} + +// checkDependencyForCycle checks if a dependency creates a cycle. +func (r *Resolver) checkDependencyForCycle(dep string, visited, recStack map[string]bool, cyclePath *[]string) bool { + if !visited[dep] { + return r.dfsFindCycle(dep, visited, recStack, cyclePath) + } + if recStack[dep] { + // Found cycle - trim cyclePath to start at dep. + r.trimCyclePathToStart(dep, cyclePath) + return true + } + return false +} + +// trimCyclePathToStart trims the cycle path to start at the given dependency. +func (r *Resolver) trimCyclePathToStart(dep string, cyclePath *[]string) { + for i, n := range *cyclePath { + if n == dep { + *cyclePath = append((*cyclePath)[i:], dep) + return + } + } +} + +// formatCyclePath formats the cycle path as "a → b → c → a". +func (r *Resolver) formatCyclePath(cyclePath []string) string { + var result strings.Builder + for i, name := range cyclePath { + if i > 0 { + result.WriteString(" → ") + } + result.WriteString(name) + } + return result.String() +} + +// resolveLocal resolves a single local's value using the template engine. +func (r *Resolver) resolveLocal(name string) (any, error) { + defer perf.Track(nil, "locals.Resolver.resolveLocal")() + + value := r.locals[name] + return r.resolveValue(value, name) +} + +// resolveValue recursively resolves template expressions in a value. +func (r *Resolver) resolveValue(value any, localName string) (any, error) { + switch v := value.(type) { + case string: + return r.resolveString(v, localName) + + case map[string]any: + result := make(map[string]any, len(v)) + for key, val := range v { + resolved, err := r.resolveValue(val, localName) + if err != nil { + return nil, err + } + result[key] = resolved + } + return result, nil + + case []any: + result := make([]any, len(v)) + for i, elem := range v { + resolved, err := r.resolveValue(elem, localName) + if err != nil { + return nil, err + } + result[i] = resolved + } + return result, nil + + default: + // Non-string/map/slice values pass through unchanged. + return value, nil + } +} + +// resolveString resolves template expressions in a string value. +func (r *Resolver) resolveString(strVal, localName string) (any, error) { + // Quick check - if no template delimiters, return as-is. + if !strings.Contains(strVal, "{{") { + return strVal, nil + } + + // Build template context with resolved locals. + context := map[string]any{ + "locals": r.resolved, + } + + // Parse and execute the template. + tmpl, err := template.New(localName).Funcs(sprig.FuncMap()).Parse(strVal) + if err != nil { + return nil, fmt.Errorf("failed to parse template for local %q in %s: %w", localName, r.filePath, err) + } + + // Error on missing keys to catch undefined local references. + tmpl.Option("missingkey=error") + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, context); err != nil { + // Provide helpful error message with available locals. + availableLocals := r.getAvailableLocals() + return nil, fmt.Errorf("failed to resolve local %q in %s: %w\n\nAvailable locals at this scope:\n%s", + localName, r.filePath, err, availableLocals) + } + + return buf.String(), nil +} + +// getAvailableLocals returns a formatted string of available locals for error messages. +func (r *Resolver) getAvailableLocals() string { + if len(r.resolved) == 0 { + return " (none)" + } + + names := make([]string, 0, len(r.resolved)) + for name := range r.resolved { + names = append(names, name) + } + sort.Strings(names) + + var sb strings.Builder + for _, name := range names { + sb.WriteString(" - ") + sb.WriteString(name) + sb.WriteString("\n") + } + return sb.String() +} + +// GetDependencies returns the dependency graph for testing/debugging. +func (r *Resolver) GetDependencies() map[string][]string { + defer perf.Track(nil, "locals.Resolver.GetDependencies")() + + result := make(map[string][]string, len(r.dependencies)) + for k, v := range r.dependencies { + result[k] = append([]string{}, v...) + } + return result +} diff --git a/pkg/locals/resolver_test.go b/pkg/locals/resolver_test.go new file mode 100644 index 0000000000..717218e181 --- /dev/null +++ b/pkg/locals/resolver_test.go @@ -0,0 +1,615 @@ +package locals + +import ( + "errors" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + errUtils "github.com/cloudposse/atmos/errors" +) + +func TestResolver_SimpleResolution(t *testing.T) { + locals := map[string]any{ + "a": "value-a", + "b": "{{ .locals.a }}-extended", + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, "value-a", result["a"]) + assert.Equal(t, "value-a-extended", result["b"]) +} + +func TestResolver_ChainedLocals(t *testing.T) { + locals := map[string]any{ + "a": "start", + "b": "{{ .locals.a }}-middle", + "c": "{{ .locals.b }}-end", + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, "start", result["a"]) + assert.Equal(t, "start-middle", result["b"]) + assert.Equal(t, "start-middle-end", result["c"]) +} + +func TestResolver_OrderIndependent(t *testing.T) { + // Defined in reverse dependency order. + locals := map[string]any{ + "c": "{{ .locals.b }}-c", + "b": "{{ .locals.a }}-b", + "a": "start", + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, "start", result["a"]) + assert.Equal(t, "start-b", result["b"]) + assert.Equal(t, "start-b-c", result["c"]) +} + +func TestResolver_ParentScopeAccess(t *testing.T) { + parentLocals := map[string]any{ + "global": "from-parent", + } + locals := map[string]any{ + "child": "{{ .locals.global }}-child", + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(parentLocals) + + require.NoError(t, err) + assert.Equal(t, "from-parent", result["global"]) // Parent local preserved. + assert.Equal(t, "from-parent-child", result["child"]) +} + +func TestResolver_CycleDetection(t *testing.T) { + locals := map[string]any{ + "a": "{{ .locals.c }}", + "b": "{{ .locals.a }}", + "c": "{{ .locals.b }}", + } + + resolver := NewResolver(locals, "test.yaml") + _, err := resolver.Resolve(nil) + + require.Error(t, err) + assert.True(t, errors.Is(err, errUtils.ErrLocalsCircularDep), "error should be ErrLocalsCircularDep sentinel") + assert.Contains(t, err.Error(), "circular dependency") + // The cycle should be detected (order may vary due to map iteration). + assert.Contains(t, err.Error(), "→") +} + +func TestResolver_SelfReference(t *testing.T) { + locals := map[string]any{ + "a": "{{ .locals.a }}", + } + + resolver := NewResolver(locals, "test.yaml") + _, err := resolver.Resolve(nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") +} + +func TestResolver_NonStringValues(t *testing.T) { + locals := map[string]any{ + "number": 42, + "boolean": true, + "list": []any{1, 2, 3}, + "map": map[string]any{"key": "value"}, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, 42, result["number"]) + assert.Equal(t, true, result["boolean"]) + assert.Equal(t, []any{1, 2, 3}, result["list"]) + assert.Equal(t, map[string]any{"key": "value"}, result["map"]) +} + +func TestResolver_EmptyLocals(t *testing.T) { + resolver := NewResolver(map[string]any{}, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestResolver_EmptyLocalsWithParent(t *testing.T) { + parentLocals := map[string]any{ + "parent": "value", + } + + resolver := NewResolver(map[string]any{}, "test.yaml") + result, err := resolver.Resolve(parentLocals) + + require.NoError(t, err) + assert.Equal(t, "value", result["parent"]) +} + +func TestResolver_NilLocals(t *testing.T) { + resolver := NewResolver(nil, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestResolver_MultipleRefsInOneLocal(t *testing.T) { + locals := map[string]any{ + "a": "first", + "b": "second", + "c": "third", + "result": "{{ .locals.a }}-{{ .locals.b }}-{{ .locals.c }}", + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, "first-second-third", result["result"]) +} + +func TestResolver_SprigFunctions(t *testing.T) { + locals := map[string]any{ + "name": "hello", + "upper": "{{ .locals.name | upper }}", + "quoted": `{{ .locals.name | quote }}`, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, "hello", result["name"]) + assert.Equal(t, "HELLO", result["upper"]) + assert.Equal(t, `"hello"`, result["quoted"]) +} + +func TestResolver_ConditionalTemplate(t *testing.T) { + locals := map[string]any{ + "env": "prod", + "result": `{{ if eq .locals.env "prod" }}production{{ else }}development{{ end }}`, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, "production", result["result"]) +} + +func TestResolver_MapWithTemplates(t *testing.T) { + locals := map[string]any{ + "prefix": "app", + "config": map[string]any{ + "name": "{{ .locals.prefix }}-service", + "port": 8080, + }, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + config := result["config"].(map[string]any) + assert.Equal(t, "app-service", config["name"]) + assert.Equal(t, 8080, config["port"]) +} + +func TestResolver_SliceWithTemplates(t *testing.T) { + locals := map[string]any{ + "domain": "example.com", + "hosts": []any{ + "www.{{ .locals.domain }}", + "api.{{ .locals.domain }}", + }, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + hosts := result["hosts"].([]any) + assert.Equal(t, "www.example.com", hosts[0]) + assert.Equal(t, "api.example.com", hosts[1]) +} + +func TestResolver_NestedMapWithTemplates(t *testing.T) { + locals := map[string]any{ + "env": "prod", + "config": map[string]any{ + "database": map[string]any{ + "host": "db-{{ .locals.env }}.example.com", + }, + }, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + config := result["config"].(map[string]any) + database := config["database"].(map[string]any) + assert.Equal(t, "db-prod.example.com", database["host"]) +} + +func TestResolver_NoTemplateString(t *testing.T) { + locals := map[string]any{ + "plain": "just a plain string without templates", + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, "just a plain string without templates", result["plain"]) +} + +func TestResolver_GetDependencies(t *testing.T) { + locals := map[string]any{ + "a": "value", + "b": "{{ .locals.a }}", + "c": "{{ .locals.a }}-{{ .locals.b }}", + } + + resolver := NewResolver(locals, "test.yaml") + err := resolver.buildDependencyGraph() + require.NoError(t, err) + + deps := resolver.GetDependencies() + + assert.Empty(t, deps["a"]) + assert.Equal(t, []string{"a"}, deps["b"]) + + // Sort for deterministic comparison. + sort.Strings(deps["c"]) + assert.Equal(t, []string{"a", "b"}, deps["c"]) +} + +func TestResolver_ParentDoesNotCreateCycle(t *testing.T) { + // Parent scope local should not create a cycle. + parentLocals := map[string]any{ + "parent": "parent-value", + } + locals := map[string]any{ + "child": "{{ .locals.parent }}-extended", + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(parentLocals) + + require.NoError(t, err) + assert.Equal(t, "parent-value-extended", result["child"]) +} + +func TestResolver_OverrideParentLocal(t *testing.T) { + parentLocals := map[string]any{ + "shared": "parent-value", + } + locals := map[string]any{ + "shared": "child-value", // Override parent. + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(parentLocals) + + require.NoError(t, err) + assert.Equal(t, "child-value", result["shared"]) +} + +func TestResolver_UndefinedLocalError(t *testing.T) { + locals := map[string]any{ + "a": "{{ .locals.undefined }}", + } + + resolver := NewResolver(locals, "test.yaml") + _, err := resolver.Resolve(nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to resolve local") + assert.Contains(t, err.Error(), "test.yaml") +} + +func TestResolver_InvalidTemplate(t *testing.T) { + locals := map[string]any{ + "a": "{{ .locals.foo }", + } + + resolver := NewResolver(locals, "test.yaml") + _, err := resolver.Resolve(nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse template") +} + +func TestResolver_DiamondDependency(t *testing.T) { + // Diamond dependency pattern: + // a + // / \ + // b c + // \ / + // d + locals := map[string]any{ + "a": "root", + "b": "{{ .locals.a }}-left", + "c": "{{ .locals.a }}-right", + "d": "{{ .locals.b }}-{{ .locals.c }}", + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, "root", result["a"]) + assert.Equal(t, "root-left", result["b"]) + assert.Equal(t, "root-right", result["c"]) + assert.Equal(t, "root-left-root-right", result["d"]) +} + +func TestResolver_ComplexChain(t *testing.T) { + locals := map[string]any{ + "project": "myapp", + "environment": "prod", + "region": "us-east-1", + "prefix": "{{ .locals.project }}-{{ .locals.environment }}", + "full_prefix": "{{ .locals.prefix }}-{{ .locals.region }}", + "bucket_name": "{{ .locals.full_prefix }}-assets", + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, "myapp-prod-us-east-1-assets", result["bucket_name"]) +} + +func TestResolver_UndefinedLocalShowsAvailableLocals(t *testing.T) { + // Test that when a local references an undefined local, + // the error message includes the list of available locals. + // Note: "aaa" and "aab" are alphabetically before "zzz", so they resolve first. + locals := map[string]any{ + "aaa": "value1", + "aab": "value2", + "zzz": "{{ .locals.undefined }}", + } + + resolver := NewResolver(locals, "test.yaml") + _, err := resolver.Resolve(nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Available locals") + assert.Contains(t, err.Error(), "aaa") + assert.Contains(t, err.Error(), "aab") +} + +func TestResolver_UndefinedLocalNoAvailableLocals(t *testing.T) { + // Test error message when no locals are available (shows "(none)"). + locals := map[string]any{ + "bad": "{{ .locals.undefined }}", + } + + resolver := NewResolver(locals, "test.yaml") + _, err := resolver.Resolve(nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "Available locals") + assert.Contains(t, err.Error(), "(none)") +} + +func TestResolver_ComplexCycleDetection(t *testing.T) { + // Test a more complex cycle: a → b → c → d → b (not back to a). + locals := map[string]any{ + "a": "{{ .locals.b }}", + "b": "{{ .locals.c }}", + "c": "{{ .locals.d }}", + "d": "{{ .locals.b }}", // Creates cycle b → c → d → b + } + + resolver := NewResolver(locals, "test.yaml") + _, err := resolver.Resolve(nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") + assert.Contains(t, err.Error(), "→") +} + +func TestResolver_CycleWithParentLocalReference(t *testing.T) { + // Cycle should still be detected even with parent local references. + parentLocals := map[string]any{ + "parent": "parent-value", + } + locals := map[string]any{ + "a": "{{ .locals.parent }}-{{ .locals.b }}", + "b": "{{ .locals.a }}", + } + + resolver := NewResolver(locals, "test.yaml") + _, err := resolver.Resolve(parentLocals) + + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") +} + +func TestResolver_MapWithNestedDependencies(t *testing.T) { + // Test dependency extraction from deeply nested maps. + locals := map[string]any{ + "base": "root", + "config": map[string]any{ + "level1": map[string]any{ + "level2": "{{ .locals.base }}-nested", + }, + }, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + config := result["config"].(map[string]any) + level1 := config["level1"].(map[string]any) + assert.Equal(t, "root-nested", level1["level2"]) +} + +func TestResolver_SliceWithMixedTypes(t *testing.T) { + // Test slice with mixed types including templates. + locals := map[string]any{ + "prefix": "item", + "items": []any{ + "{{ .locals.prefix }}-1", + 42, + true, + map[string]any{"name": "{{ .locals.prefix }}-nested"}, + }, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + items := result["items"].([]any) + assert.Equal(t, "item-1", items[0]) + assert.Equal(t, 42, items[1]) + assert.Equal(t, true, items[2]) + nested := items[3].(map[string]any) + assert.Equal(t, "item-nested", nested["name"]) +} + +func TestResolver_MapWithMultipleDependencies(t *testing.T) { + // Test map value with multiple local references for dependency extraction. + locals := map[string]any{ + "a": "first", + "b": "second", + "c": "third", + "combined": map[string]any{ + "all": "{{ .locals.a }}-{{ .locals.b }}-{{ .locals.c }}", + }, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + combined := result["combined"].(map[string]any) + assert.Equal(t, "first-second-third", combined["all"]) +} + +func TestResolver_SliceWithDependencyChain(t *testing.T) { + // Test slice elements that form a dependency chain. + locals := map[string]any{ + "base": "root", + "mid": "{{ .locals.base }}-mid", + "items": []any{ + "{{ .locals.mid }}-item1", + "{{ .locals.base }}-item2", + }, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + items := result["items"].([]any) + assert.Equal(t, "root-mid-item1", items[0]) + assert.Equal(t, "root-item2", items[1]) +} + +func TestResolver_FloatAndNilValues(t *testing.T) { + // Test that float and nil values pass through unchanged. + locals := map[string]any{ + "float_val": 3.14159, + "nil_val": nil, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, 3.14159, result["float_val"]) + assert.Nil(t, result["nil_val"]) +} + +func TestResolver_EmptyMapAndSlice(t *testing.T) { + // Test empty map and slice values. + locals := map[string]any{ + "empty_map": map[string]any{}, + "empty_slice": []any{}, + } + + resolver := NewResolver(locals, "test.yaml") + result, err := resolver.Resolve(nil) + + require.NoError(t, err) + assert.Equal(t, map[string]any{}, result["empty_map"]) + assert.Equal(t, []any{}, result["empty_slice"]) +} + +func TestResolver_LargeCycle(t *testing.T) { + // Test cycle detection with a larger cycle. + locals := map[string]any{ + "a": "{{ .locals.b }}", + "b": "{{ .locals.c }}", + "c": "{{ .locals.d }}", + "d": "{{ .locals.e }}", + "e": "{{ .locals.a }}", // Creates cycle a → b → c → d → e → a + } + + resolver := NewResolver(locals, "test.yaml") + _, err := resolver.Resolve(nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") +} + +func TestResolver_MultipleSelfReferences(t *testing.T) { + // Test multiple self-referencing locals. + locals := map[string]any{ + "a": "{{ .locals.a }}", + "b": "{{ .locals.b }}", + } + + resolver := NewResolver(locals, "test.yaml") + _, err := resolver.Resolve(nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") +} + +func TestResolver_DependencyGraphWithParentRefs(t *testing.T) { + // Test that parent local references don't appear in the dependency graph. + parentLocals := map[string]any{ + "parent1": "p1", + "parent2": "p2", + } + locals := map[string]any{ + "child": "{{ .locals.parent1 }}-{{ .locals.parent2 }}", + } + + resolver := NewResolver(locals, "test.yaml") + err := resolver.buildDependencyGraph() + require.NoError(t, err) + + deps := resolver.GetDependencies() + // Dependencies include parent refs, but they're not in the local scope. + assert.Contains(t, deps["child"], "parent1") + assert.Contains(t, deps["child"], "parent2") + + // Resolve should work because parent locals are provided. + result, err := resolver.Resolve(parentLocals) + require.NoError(t, err) + assert.Equal(t, "p1-p2", result["child"]) +} diff --git a/pkg/template/ast.go b/pkg/template/ast.go new file mode 100644 index 0000000000..0e9abdc846 --- /dev/null +++ b/pkg/template/ast.go @@ -0,0 +1,226 @@ +// Package template provides utilities for Go template AST inspection and analysis. +// This package enables extraction of field references from templates, which is +// useful for dependency resolution (e.g., locals referencing other locals). +package template + +import ( + "strings" + "text/template" + "text/template/parse" + + "github.com/cloudposse/atmos/pkg/perf" +) + +// FieldRef represents a reference to a field in a template (e.g., .locals.foo). +type FieldRef struct { + Path []string // e.g., ["locals", "foo"] for .locals.foo +} + +// String returns the dot-separated path of the field reference. +// +//nolint:lintroller // Simple strings.Join - perf.Track overhead disproportionate. +func (f FieldRef) String() string { + return strings.Join(f.Path, ".") +} + +// ExtractFieldRefs parses a Go template string and extracts all field references. +// Handles complex expressions: conditionals, pipes, range, with blocks, nested templates. +// Returns nil if the string is not a valid template or contains no field references. +func ExtractFieldRefs(templateStr string) ([]FieldRef, error) { + defer perf.Track(nil, "template.ExtractFieldRefs")() + + // Quick check - if no template delimiters, no refs possible. + if !strings.Contains(templateStr, "{{") { + return nil, nil + } + + tmpl, err := template.New("").Parse(templateStr) + if err != nil { + return nil, err + } + + tree := tmpl.Tree + if tree == nil || tree.Root == nil { + return nil, nil + } + + var refs []FieldRef + seen := make(map[string]bool) + + walkAST(tree.Root, func(node parse.Node) { + if field, ok := node.(*parse.FieldNode); ok { + key := fieldKey(field.Ident) + if !seen[key] { + refs = append(refs, FieldRef{Path: field.Ident}) + seen[key] = true + } + } + }) + + return refs, nil +} + +// ExtractFieldRefsByPrefix extracts field references that start with a specific prefix. +// For example, ExtractFieldRefsByPrefix(tmpl, "locals") returns all .locals.X references. +// Returns the second-level identifiers (e.g., "foo" for .locals.foo). +func ExtractFieldRefsByPrefix(templateStr string, prefix string) ([]string, error) { + defer perf.Track(nil, "template.ExtractFieldRefsByPrefix")() + + refs, err := ExtractFieldRefs(templateStr) + if err != nil { + return nil, err + } + + seen := make(map[string]bool) + var result []string + for _, ref := range refs { + if len(ref.Path) >= 2 && ref.Path[0] == prefix { + name := ref.Path[1] + if !seen[name] { + result = append(result, name) + seen[name] = true + } + } + } + return result, nil +} + +// walkAST traverses all nodes in a template AST, calling fn for each node. +// This handles all Go template node types including conditionals, ranges, +// with blocks, and nested templates. +func walkAST(node parse.Node, fn func(parse.Node)) { + if node == nil { + return + } + + fn(node) + + switch n := node.(type) { + case *parse.ListNode: + walkListNode(n, fn) + + case *parse.ActionNode: + walkAST(n.Pipe, fn) + + case *parse.PipeNode: + walkPipeNode(n, fn) + + case *parse.CommandNode: + walkCommandNode(n, fn) + + case *parse.IfNode: + walkBranchNode(n.Pipe, n.List, n.ElseList, fn) + + case *parse.RangeNode: + walkBranchNode(n.Pipe, n.List, n.ElseList, fn) + + case *parse.WithNode: + walkBranchNode(n.Pipe, n.List, n.ElseList, fn) + + case *parse.TemplateNode: + walkAST(n.Pipe, fn) + } +} + +// walkListNode traverses a ListNode and processes its children. +func walkListNode(n *parse.ListNode, fn func(parse.Node)) { + if n == nil { + return + } + for _, child := range n.Nodes { + walkAST(child, fn) + } +} + +// walkPipeNode traverses a PipeNode and processes commands and declarations. +func walkPipeNode(n *parse.PipeNode, fn func(parse.Node)) { + if n == nil { + return + } + for _, cmd := range n.Cmds { + walkAST(cmd, fn) + } + for _, decl := range n.Decl { + walkAST(decl, fn) + } +} + +// walkCommandNode traverses a CommandNode and processes arguments. +func walkCommandNode(n *parse.CommandNode, fn func(parse.Node)) { + if n == nil { + return + } + for _, arg := range n.Args { + walkAST(arg, fn) + } +} + +// walkBranchNode traverses branch nodes (if/range/with) with pipe, list, and else-list. +func walkBranchNode(pipe *parse.PipeNode, list, elseList *parse.ListNode, fn func(parse.Node)) { + walkAST(pipe, fn) + walkAST(list, fn) + walkAST(elseList, fn) +} + +// fieldKey creates a unique key from a field path for deduplication. +func fieldKey(ident []string) string { + return strings.Join(ident, ".") +} + +// HasTemplateActions checks if a string contains Go template actions. +// This is a more robust version that uses AST parsing instead of simple string matching. +func HasTemplateActions(str string) (bool, error) { + defer perf.Track(nil, "template.HasTemplateActions")() + + // Quick check - if no template delimiters, no actions possible. + if !strings.Contains(str, "{{") { + return false, nil + } + + tmpl, err := template.New("").Parse(str) + if err != nil { + return false, err + } + + tree := tmpl.Tree + if tree == nil || tree.Root == nil { + return false, nil + } + + hasActions := false + walkAST(tree.Root, func(node parse.Node) { + switch node.(type) { + case *parse.ActionNode, *parse.IfNode, *parse.RangeNode, *parse.WithNode: + hasActions = true + } + }) + + return hasActions, nil +} + +// ExtractAllFieldRefsByPrefix extracts all field references that start with a specific prefix, +// returning the full remaining path after the prefix. +// For example, ExtractAllFieldRefsByPrefix(tmpl, "locals") for .locals.config.nested +// returns ["config.nested"]. +func ExtractAllFieldRefsByPrefix(templateStr string, prefix string) ([]string, error) { + defer perf.Track(nil, "template.ExtractAllFieldRefsByPrefix")() + + refs, err := ExtractFieldRefs(templateStr) + if err != nil { + return nil, err + } + + seen := make(map[string]bool) + var result []string + for _, ref := range refs { + if len(ref.Path) >= 2 && ref.Path[0] == prefix { + // Join all path elements after the prefix. + fullPath := strings.Join(ref.Path[1:], ".") + if !seen[fullPath] { + result = append(result, fullPath) + seen[fullPath] = true + } + } + } + return result, nil +} diff --git a/pkg/template/ast_test.go b/pkg/template/ast_test.go new file mode 100644 index 0000000000..74e09d4f58 --- /dev/null +++ b/pkg/template/ast_test.go @@ -0,0 +1,442 @@ +package template + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtractFieldRefs(t *testing.T) { + tests := []struct { + name string + template string + expected [][]string // Path slices + }{ + { + name: "simple field", + template: "{{ .foo }}", + expected: [][]string{{"foo"}}, + }, + { + name: "nested field", + template: "{{ .foo.bar }}", + expected: [][]string{{"foo", "bar"}}, + }, + { + name: "multiple fields", + template: "{{ .foo }}-{{ .bar }}", + expected: [][]string{{"foo"}, {"bar"}}, + }, + { + name: "deeply nested", + template: "{{ .a.b.c.d }}", + expected: [][]string{{"a", "b", "c", "d"}}, + }, + { + name: "duplicate refs deduplicated", + template: "{{ .foo }}-{{ .foo }}", + expected: [][]string{{"foo"}}, + }, + { + name: "no template syntax", + template: "just a plain string", + expected: nil, + }, + { + name: "empty string", + template: "", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + refs, err := ExtractFieldRefs(tt.template) + require.NoError(t, err) + + if tt.expected == nil { + assert.Nil(t, refs) + return + } + + assert.Len(t, refs, len(tt.expected)) + for i, expectedPath := range tt.expected { + assert.Equal(t, expectedPath, refs[i].Path) + } + }) + } +} + +func TestExtractFieldRefsByPrefix(t *testing.T) { + tests := []struct { + name string + template string + prefix string + expected []string + }{ + { + name: "simple local ref", + template: "{{ .locals.foo }}", + prefix: "locals", + expected: []string{"foo"}, + }, + { + name: "multiple local refs", + template: "{{ .locals.foo }}-{{ .locals.bar }}", + prefix: "locals", + expected: []string{"foo", "bar"}, + }, + { + name: "conditional with multiple refs", + template: "{{ if .locals.flag }}{{ .locals.x }}{{ else }}{{ .locals.y }}{{ end }}", + prefix: "locals", + expected: []string{"flag", "x", "y"}, + }, + { + name: "pipe expression", + template: `{{ .locals.foo | printf "%s-%s" .locals.bar }}`, + prefix: "locals", + expected: []string{"foo", "bar"}, + }, + { + name: "range block", + template: "{{ range .locals.items }}{{ .locals.prefix }}-{{ . }}{{ end }}", + prefix: "locals", + expected: []string{"items", "prefix"}, + }, + { + name: "with block - context change", + template: "{{ with .locals.config }}{{ .name }}{{ end }}", + prefix: "locals", + expected: []string{"config"}, // .name is NOT .locals.name inside with block + }, + { + name: "mixed prefixes - only locals", + template: "{{ .locals.a }}-{{ .vars.b }}-{{ .settings.c }}", + prefix: "locals", + expected: []string{"a"}, + }, + { + name: "mixed prefixes - only vars", + template: "{{ .locals.a }}-{{ .vars.b }}-{{ .settings.c }}", + prefix: "vars", + expected: []string{"b"}, + }, + { + name: "nested conditionals", + template: "{{ if .locals.a }}{{ if .locals.b }}{{ .locals.c }}{{ end }}{{ end }}", + prefix: "locals", + expected: []string{"a", "b", "c"}, + }, + { + name: "no template syntax", + template: "just a plain string", + prefix: "locals", + expected: nil, + }, + { + name: "deep path - only first level after prefix", + template: "{{ .locals.config.nested.value }}", + prefix: "locals", + expected: []string{"config"}, + }, + { + name: "comparison in if", + template: `{{ if eq .locals.env "prod" }}{{ .locals.value }}{{ end }}`, + prefix: "locals", + expected: []string{"env", "value"}, + }, + { + name: "single pipe with builtin", + template: "{{ .locals.name | len }}", + prefix: "locals", + expected: []string{"name"}, + }, + { + name: "printf with multiple refs", + template: `{{ printf "%s-%s-%s" .locals.a .locals.b .locals.c }}`, + prefix: "locals", + expected: []string{"a", "b", "c"}, + }, + { + name: "else if chain", + template: "{{ if .locals.a }}x{{ else if .locals.b }}y{{ else }}{{ .locals.c }}{{ end }}", + prefix: "locals", + expected: []string{"a", "b", "c"}, + }, + { + name: "range with else", + template: "{{ range .locals.items }}{{ . }}{{ else }}{{ .locals.empty }}{{ end }}", + prefix: "locals", + expected: []string{"items", "empty"}, + }, + { + name: "duplicate refs deduplicated", + template: "{{ .locals.foo }}-{{ .locals.foo }}-{{ .locals.foo }}", + prefix: "locals", + expected: []string{"foo"}, + }, + { + name: "no prefix match", + template: "{{ .vars.foo }}", + prefix: "locals", + expected: nil, + }, + { + name: "single path element - no match", + template: "{{ .foo }}", + prefix: "locals", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ExtractFieldRefsByPrefix(tt.template, tt.prefix) + require.NoError(t, err) + + // Sort for deterministic comparison. + sort.Strings(result) + if tt.expected != nil { + sort.Strings(tt.expected) + } + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractAllFieldRefsByPrefix(t *testing.T) { + tests := []struct { + name string + template string + prefix string + expected []string + }{ + { + name: "simple ref", + template: "{{ .locals.foo }}", + prefix: "locals", + expected: []string{"foo"}, + }, + { + name: "nested ref - full path", + template: "{{ .locals.config.nested.value }}", + prefix: "locals", + expected: []string{"config.nested.value"}, + }, + { + name: "multiple nested refs", + template: "{{ .locals.a.b }}-{{ .locals.x.y.z }}", + prefix: "locals", + expected: []string{"a.b", "x.y.z"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ExtractAllFieldRefsByPrefix(tt.template, tt.prefix) + require.NoError(t, err) + + sort.Strings(result) + if tt.expected != nil { + sort.Strings(tt.expected) + } + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasTemplateActions(t *testing.T) { + tests := []struct { + template string + expected bool + }{ + {"{{ .foo }}", true}, + {"{{ if .x }}y{{ end }}", true}, + {"{{ range .items }}{{ . }}{{ end }}", true}, + {"{{ with .config }}{{ .value }}{{ end }}", true}, + {"plain text", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.template, func(t *testing.T) { + result, err := HasTemplateActions(tt.template) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFieldRefString(t *testing.T) { + tests := []struct { + path []string + expected string + }{ + {[]string{"foo"}, "foo"}, + {[]string{"locals", "bar"}, "locals.bar"}, + {[]string{"a", "b", "c"}, "a.b.c"}, + {nil, ""}, + {[]string{}, ""}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + ref := FieldRef{Path: tt.path} + assert.Equal(t, tt.expected, ref.String()) + }) + } +} + +func TestExtractFieldRefs_InvalidTemplate(t *testing.T) { + // Invalid template syntax should return an error. + _, err := ExtractFieldRefs("{{ .foo }") + assert.Error(t, err) +} + +func TestExtractFieldRefsByPrefix_InvalidTemplate(t *testing.T) { + // Invalid template syntax should return an error. + _, err := ExtractFieldRefsByPrefix("{{ .locals.foo }", "locals") + assert.Error(t, err) +} + +func TestHasTemplateActions_InvalidTemplate(t *testing.T) { + // Invalid template syntax should return an error. + _, err := HasTemplateActions("{{ .foo }") + assert.Error(t, err) +} + +func TestExtractFieldRefs_NilTreeRoot(t *testing.T) { + // Test with a template that parses but has no meaningful content. + refs, err := ExtractFieldRefs("plain text without any template") + require.NoError(t, err) + assert.Nil(t, refs) +} + +func TestExtractFieldRefs_TemplateNode(t *testing.T) { + // Test template that invokes another template with a pipe argument. + // The .config field reference should be captured from the template invocation. + refs, err := ExtractFieldRefs(`{{ template "inner" .config }}`) + require.NoError(t, err) + // The field reference in the template invocation is captured. + assert.NotNil(t, refs) + assert.Len(t, refs, 1) + assert.Equal(t, []string{"config"}, refs[0].Path) +} + +func TestExtractAllFieldRefsByPrefix_NoMatch(t *testing.T) { + // Test when no refs match the prefix. + result, err := ExtractAllFieldRefsByPrefix("{{ .vars.foo }}", "locals") + require.NoError(t, err) + assert.Nil(t, result) +} + +func TestExtractAllFieldRefsByPrefix_InvalidTemplate(t *testing.T) { + // Invalid template should return an error. + _, err := ExtractAllFieldRefsByPrefix("{{ .locals.foo }", "locals") + assert.Error(t, err) +} + +func TestExtractAllFieldRefsByPrefix_NoTemplateDelimiters(t *testing.T) { + // No template delimiters means no refs. + result, err := ExtractAllFieldRefsByPrefix("plain text", "locals") + require.NoError(t, err) + assert.Nil(t, result) +} + +func TestExtractFieldRefs_PipeWithDeclarations(t *testing.T) { + // Test pipe node with variable declarations. + refs, err := ExtractFieldRefs(`{{ $x := .foo }}{{ $x }}`) + require.NoError(t, err) + assert.Len(t, refs, 1) + assert.Equal(t, []string{"foo"}, refs[0].Path) +} + +func TestExtractFieldRefs_RangeWithElse(t *testing.T) { + // Test range block with else clause. + refs, err := ExtractFieldRefs(`{{ range .items }}{{ .name }}{{ else }}{{ .empty }}{{ end }}`) + require.NoError(t, err) + // Should capture .items, .name (within range context), .empty + assert.NotEmpty(t, refs) + // Verify specific expected references. + refPaths := make(map[string]bool) + for _, ref := range refs { + refPaths[ref.String()] = true + } + assert.True(t, refPaths["items"], "should contain reference to .items") + assert.True(t, refPaths["name"], "should contain reference to .name") + assert.True(t, refPaths["empty"], "should contain reference to .empty") +} + +func TestExtractFieldRefs_WithBlock(t *testing.T) { + // Test with block that changes context. + refs, err := ExtractFieldRefs(`{{ with .config }}{{ .value }}{{ end }}`) + require.NoError(t, err) + // Should capture both .config and .value + foundConfig := false + foundValue := false + for _, ref := range refs { + if len(ref.Path) == 1 && ref.Path[0] == "config" { + foundConfig = true + } + if len(ref.Path) == 1 && ref.Path[0] == "value" { + foundValue = true + } + } + assert.True(t, foundConfig, "should capture .config") + assert.True(t, foundValue, "should capture .value") +} + +func TestExtractFieldRefs_NestedRangeAndIf(t *testing.T) { + // Test deeply nested control structures. + refs, err := ExtractFieldRefs(`{{ range .items }}{{ if .active }}{{ .name }}{{ end }}{{ end }}`) + require.NoError(t, err) + assert.NotEmpty(t, refs) +} + +func TestExtractFieldRefs_CommandWithMultipleArgs(t *testing.T) { + // Test command node with multiple arguments. + refs, err := ExtractFieldRefs(`{{ printf "%s %s" .first .second }}`) + require.NoError(t, err) + assert.Len(t, refs, 2) +} + +func TestHasTemplateActions_RangeAction(t *testing.T) { + // Test that range is detected as an action. + result, err := HasTemplateActions(`{{ range .items }}{{ . }}{{ end }}`) + require.NoError(t, err) + assert.True(t, result) +} + +func TestHasTemplateActions_WithAction(t *testing.T) { + // Test that with is detected as an action. + result, err := HasTemplateActions(`{{ with .config }}{{ .value }}{{ end }}`) + require.NoError(t, err) + assert.True(t, result) +} + +func TestHasTemplateActions_NoActionsJustText(t *testing.T) { + // Test that text nodes without actions return false. + result, err := HasTemplateActions(`{{ "literal text" }}`) + require.NoError(t, err) + // This is actually an action (ActionNode), so it returns true. + assert.True(t, result) +} + +func TestExtractFieldRefsByPrefix_SinglePathElement(t *testing.T) { + // When path has only one element, it doesn't match prefix.second pattern. + result, err := ExtractFieldRefsByPrefix("{{ .foo }}", "foo") + require.NoError(t, err) + // .foo doesn't match the pattern .foo.X + assert.Nil(t, result) +} + +func TestExtractFieldRefs_ElseIfChain(t *testing.T) { + // Test else-if chain parsing. + refs, err := ExtractFieldRefs(`{{ if .a }}1{{ else if .b }}2{{ else if .c }}3{{ else }}{{ .d }}{{ end }}`) + require.NoError(t, err) + // Should find exactly a, b, c, d. + assert.Len(t, refs, 4, "else-if chain should have exactly 4 references") +} diff --git a/tests/fixtures/scenarios/locals-circular/atmos.yaml b/tests/fixtures/scenarios/locals-circular/atmos.yaml new file mode 100644 index 0000000000..7690bd62f1 --- /dev/null +++ b/tests/fixtures/scenarios/locals-circular/atmos.yaml @@ -0,0 +1,31 @@ +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: [] + 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-circular/stacks/deploy/circular.yaml b/tests/fixtures/scenarios/locals-circular/stacks/deploy/circular.yaml new file mode 100644 index 0000000000..4d7ab9fd62 --- /dev/null +++ b/tests/fixtures/scenarios/locals-circular/stacks/deploy/circular.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +# This file contains circular dependency in locals for testing error detection. +locals: + a: "{{ .locals.b }}" + b: "{{ .locals.c }}" + c: "{{ .locals.a }}" + +vars: + stage: "dev" + +components: + terraform: + mock: + metadata: + component: mock + vars: + foo: "bar" diff --git a/tests/fixtures/scenarios/locals/atmos.yaml b/tests/fixtures/scenarios/locals/atmos.yaml new file mode 100644 index 0000000000..d7799fd1ee --- /dev/null +++ b/tests/fixtures/scenarios/locals/atmos.yaml @@ -0,0 +1,34 @@ +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.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/stacks/deploy/dev.yaml b/tests/fixtures/scenarios/locals/stacks/deploy/dev.yaml new file mode 100644 index 0000000000..8f435cbf9e --- /dev/null +++ b/tests/fixtures/scenarios/locals/stacks/deploy/dev.yaml @@ -0,0 +1,66 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +import: + - mixins/region + +# Global locals - available to all sections in this file. +# Note: The locals from the imported mixin should NOT be inherited here. +locals: + # Simple values. + namespace: "acme" + environment: "dev" + stage: "us-east-1" + + # Locals referencing other locals (tests dependency resolution). + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" + full_name: "{{ .locals.name_prefix }}-{{ .locals.stage }}" + + # Complex value. + tags: + Environment: "{{ .locals.environment }}" + Namespace: "{{ .locals.namespace }}" + +vars: + environment: "{{ .locals.environment }}" + stage: "{{ .locals.stage }}" + +terraform: + # Terraform-scope locals - inherit from global, can override. + locals: + tf_specific: "terraform-only" + backend_bucket: "{{ .locals.namespace }}-{{ .locals.environment }}-tfstate" + + backend_type: s3 + backend: + s3: + encrypt: true + bucket: "{{ .locals.backend_bucket }}" + key: "terraform.tfstate" + region: "us-east-1" + +components: + terraform: + 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 }}" + bar: "{{ .locals.environment }}" + baz: "{{ .locals.instance_id }}" + 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 }}" + bar: "{{ .locals.environment }}" + baz: "{{ .locals.instance_id }}" + tags: "{{ .locals.tags }}" diff --git a/tests/fixtures/scenarios/locals/stacks/deploy/prod.yaml b/tests/fixtures/scenarios/locals/stacks/deploy/prod.yaml new file mode 100644 index 0000000000..f19ee1a00e --- /dev/null +++ b/tests/fixtures/scenarios/locals/stacks/deploy/prod.yaml @@ -0,0 +1,45 @@ +# yaml-language-server: $schema=https://atmos.tools/schemas/atmos/atmos-manifest/1.0/atmos-manifest.json + +import: + - mixins/region + +# Global locals for prod environment. +locals: + namespace: "acme" + environment: "prod" + stage: "us-east-1" + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" + full_name: "{{ .locals.name_prefix }}-{{ .locals.stage }}" + tags: + Environment: "{{ .locals.environment }}" + Namespace: "{{ .locals.namespace }}" + +vars: + environment: "{{ .locals.environment }}" + stage: "{{ .locals.stage }}" + +terraform: + locals: + tf_specific: "terraform-only" + backend_bucket: "{{ .locals.namespace }}-{{ .locals.environment }}-tfstate" + backend_type: s3 + backend: + s3: + encrypt: true + bucket: "{{ .locals.backend_bucket }}" + key: "terraform.tfstate" + region: "{{ .locals.stage }}" + +components: + terraform: + mock/primary: + metadata: + component: mock + locals: + instance_id: "primary" + app_name: "{{ .locals.name_prefix }}-mock-{{ .locals.instance_id }}" + vars: + foo: "{{ .locals.app_name }}" + bar: "{{ .locals.environment }}" + baz: "{{ .locals.instance_id }}" + tags: "{{ .locals.tags }}" diff --git a/tests/fixtures/scenarios/locals/stacks/mixins/region.yaml b/tests/fixtures/scenarios/locals/stacks/mixins/region.yaml new file mode 100644 index 0000000000..29710ec9d4 --- /dev/null +++ b/tests/fixtures/scenarios/locals/stacks/mixins/region.yaml @@ -0,0 +1,10 @@ +# 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. +# This tests file-scoped isolation. +locals: + mixin_region: "us-west-2" + mixin_prefix: "mixin" + +vars: + region: "us-west-2" 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 7df8f7b3d7..f6c5eb3b0f 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 @@ -28,6 +28,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "components": { "$ref": "#/definitions/components" }, @@ -164,7 +167,7 @@ }, { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "properties": { "terraform": { "$ref": "#/definitions/terraform_components" @@ -204,6 +207,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -276,6 +282,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -323,6 +332,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -380,6 +392,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -409,6 +424,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -466,6 +484,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -503,17 +524,13 @@ "real" ] }, - "name": { - "type": "string", - "description": "Logical component identity used for workspace key prefix auto-generation. Inheritable from base components." - }, "enabled": { "type": "boolean", "description": "Flag to enable or disable the component" }, "component": { "type": "string", - "description": "Terraform/OpenTofu/Helmfile/Packer component" + "description": "Terraform/OpenTofu/Helmfile component" }, "inherits": { "oneOf": [ @@ -547,7 +564,7 @@ }, { "type": "object", - "description": "Custom configuration per component. Inherited when stacks.inherit.metadata is enabled (default).", + "description": "Custom configuration per component, not inherited by derived components", "additionalProperties": true, "title": "custom" } @@ -689,6 +706,20 @@ } ] }, + "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.", + "oneOf": [ + { + "type": "string", + "pattern": "^!include" + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, "backend_type": { "title": "backend_type", "description": "Backend type", @@ -920,9 +951,6 @@ { "type": "object", "properties": { - "stack": { - "type": "string" - }, "namespace": { "type": "string" }, diff --git a/website/blog/2025-12-16-file-scoped-locals.mdx b/website/blog/2025-12-16-file-scoped-locals.mdx new file mode 100644 index 0000000000..47537126ad --- /dev/null +++ b/website/blog/2025-12-16-file-scoped-locals.mdx @@ -0,0 +1,327 @@ +--- +slug: file-scoped-locals +title: "File-Scoped Locals: Simplify Stack Configuration with Temporary Variables" +authors: + - osterman +tags: + - feature + - dx +date: 2025-12-16T00:00:00.000Z +--- + +import Terminal from '@site/src/components/Terminal' + +We're introducing **file-scoped locals** to Atmos stack configurations. Inspired by Terraform and Terragrunt, locals let you define temporary variables within a single file, reducing repetition and making your configurations more readable and maintainable. + + + +## The Problem: Repetition in Stack Configurations + +Complex stack configurations often contain repeated values. You might have a naming convention that combines namespace, environment, and stage across multiple components: + +```yaml +# Before: Repetitive and error-prone +components: + terraform: + vpc: + vars: + name: acme-prod-us-east-1-vpc + tags: + Environment: prod + Namespace: acme + eks: + vars: + cluster_name: acme-prod-us-east-1-eks + tags: + Environment: prod + Namespace: acme + rds: + vars: + identifier: acme-prod-us-east-1-rds + tags: + Environment: prod + Namespace: acme +``` + +This approach has several problems: +- **Repetition** - Same values copied everywhere +- **Inconsistency risk** - Easy to mistype or forget to update all occurrences +- **Hard to refactor** - Changing a naming convention requires updates in many places + +## The Solution: File-Scoped Locals + +Locals let you define variables once and reference them throughout the file: + +```yaml +# After: Clean and DRY +locals: + namespace: acme + environment: prod + stage: us-east-1 + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}-{{ .locals.stage }}" + tags: + Environment: "{{ .locals.environment }}" + Namespace: "{{ .locals.namespace }}" + +components: + terraform: + vpc: + vars: + name: "{{ .locals.name_prefix }}-vpc" + tags: "{{ .locals.tags }}" + eks: + vars: + cluster_name: "{{ .locals.name_prefix }}-eks" + tags: "{{ .locals.tags }}" + rds: + vars: + identifier: "{{ .locals.name_prefix }}-rds" + tags: "{{ .locals.tags }}" +``` + +## Key Features + +### Locals Can Reference Other Locals + +Locals are resolved in dependency order using topological sorting. You can build complex values from simpler ones: + +```yaml +locals: + namespace: acme + environment: prod + stage: us-east-1 + # References other locals - resolved in correct order + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" + full_name: "{{ .locals.name_prefix }}-{{ .locals.stage }}" +``` + +### Circular Dependency Detection + +Atmos automatically detects circular dependencies and provides clear error messages: + +```yaml +# This will error with a clear message +locals: + a: "{{ .locals.b }}" + b: "{{ .locals.c }}" + c: "{{ .locals.a }}" # Circular! +``` + + +``` +Error: circular dependency in locals at stacks/prod.yaml + +Dependency cycle detected: + a → b → c → a +``` + + +### File-Scoped Isolation + +Unlike `vars`, locals do **not** inherit across file boundaries via `import`. This is intentional: + +```yaml +# mixins/region.yaml +locals: + region_prefix: "us-west-2" # Only available in this file + +vars: + region: us-west-2 # Inherited by importing files +``` + +```yaml +# stacks/prod.yaml +import: + - mixins/region + +# The 'region_prefix' local is NOT available here +# Only 'vars.region' is inherited +locals: + my_prefix: "prod" # This file's own locals +``` + +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: + +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 + +```yaml +# Global locals +locals: + namespace: acme + environment: prod + +terraform: + # Terraform-scope locals (inherit from global) + 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 }}" +``` + +## Inspecting Locals with `atmos describe locals` + +To see the resolved locals for any component, use the new `describe locals` command: + +```bash +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) +``` + + +### JSON Output for Automation + +For scripting and automation, use JSON format: + +```bash +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" + } +} +``` + +## Why File-Scoped? + +You might wonder why locals don't inherit across imports like `vars` do. The design 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 +4. **Clear separation** - Use `vars` for values that should propagate; use `locals` for file-internal convenience + +## Best Practices + +### Use locals for DRY configuration within a file + +```yaml +locals: + common_tags: + Team: platform + CostCenter: infrastructure + +components: + terraform: + vpc: + vars: + tags: "{{ .locals.common_tags }}" + eks: + vars: + tags: "{{ .locals.common_tags }}" +``` + +### Build complex values from simple ones + +```yaml +locals: + namespace: acme + environment: prod + region: us-east-1 + # Compose complex values + resource_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}-{{ .locals.region }}" + s3_bucket: "{{ .locals.resource_prefix }}-artifacts" + dynamodb_table: "{{ .locals.resource_prefix }}-state-lock" +``` + +### Keep locals close to their usage + +Define locals at the appropriate scope level - don't put everything at the global level: + +```yaml +# Global - used everywhere +locals: + namespace: acme + +terraform: + # Terraform-specific - only used by terraform components + locals: + state_bucket: "{{ .locals.namespace }}-tfstate" + + components: + terraform: + vpc: + # Component-specific - only used by this component + locals: + vpc_name: "{{ .locals.namespace }}-vpc" +``` + +## Get Started + +File-scoped locals are available now. Try them in your stack configurations: + +```yaml +locals: + project: myproject + env: dev + +vars: + name: "{{ .locals.project }}-{{ .locals.env }}" +``` + +## Related Features + +- [Stack Templates](/templates) - Go templating in stack manifests +- [Configuration Provenance](/cli/commands/describe/component) - Track where values come from +- [YAML Functions](/functions/yaml) - Dynamic configuration with `!terraform.output`, `!env`, etc. + +We'd love to hear how you're using locals in your configurations. Share your patterns in [GitHub Discussions](https://github.com/orgs/cloudposse/discussions) or open an issue if you encounter any problems. diff --git a/website/docs/cli/commands/terraform/usage.mdx b/website/docs/cli/commands/terraform/usage.mdx index 0a88094a01..6d016fb947 100644 --- a/website/docs/cli/commands/terraform/usage.mdx +++ b/website/docs/cli/commands/terraform/usage.mdx @@ -426,7 +426,7 @@ atmos terraform plan test/test-component -s tenant1-ue2-dev --append-user-agent
Execute the command on the components filtered by a [YQ](https://mikefarah.gitbook.io/yq) expression, in all stacks or in a specific stack. - __NOTE__: All Atmos sections are available in the expression, e.g. `vars`, `settings`, `env`, `metadata`, `backend`, etc. + __NOTE__: All Atmos sections are available in the expression, e.g. `vars`, `locals`, `settings`, `env`, `metadata`, `backend`, etc. ```shell atmos terraform plan --query '.vars.tags.team == "data"' diff --git a/website/docs/cli/configuration/stacks/index.mdx b/website/docs/cli/configuration/stacks/index.mdx index c27403ef91..08769e9ddc 100644 --- a/website/docs/cli/configuration/stacks/index.mdx +++ b/website/docs/cli/configuration/stacks/index.mdx @@ -87,7 +87,7 @@ stacks: Supports [Go templates](https://pkg.go.dev/text/template), [Atmos Template Functions](/functions/template), [Sprig Functions](https://masterminds.github.io/sprig/), [Gomplate Functions](https://docs.gomplate.ca/functions/), and [Gomplate Datasources](https://docs.gomplate.ca/datasources/). - You can use any Atmos sections (e.g., `vars`, `providers`, `settings`) that [`atmos describe component`](/cli/commands/describe/component) generates. + You can use any Atmos sections (e.g., `vars`, `locals`, `providers`, `settings`) that [`atmos describe component`](/cli/commands/describe/component) generates. **Example:** ```yaml diff --git a/website/docs/describe/stacks.mdx b/website/docs/describe/stacks.mdx index fb1d792665..14cba0cfb2 100644 --- a/website/docs/describe/stacks.mdx +++ b/website/docs/describe/stacks.mdx @@ -28,9 +28,11 @@ If the filtering options built-in to Atmos are not sufficient, redirect the outp Since the output of a Stack might be overwhelming, and we're only interested in some particular section of the configuration, the output can be filtered using flags to narrow the output by `stack`, `component-types`, `components`, and `sections`. The component sections can be further filtered -by `atmos_component`, `atmos_stack`, `atmos_stack_file`, `backend`, `backend_type`, `command`, `component`, `env`, `inheritance`, `metadata`, +by `atmos_component`, `atmos_stack`, `atmos_stack_file`, `backend`, `backend_type`, `command`, `component`, `env`, `inheritance`, `locals`, `metadata`, `overrides`, `remote_state_backend`, `remote_state_backend_type`, `settings`, `vars`, `workspace`. +Note: While `locals` can be defined at various scopes in stack manifests for DRY configuration (see [locals](/stacks/locals)), they are file-scoped and evaluated during processing. The resolved values from locals are used within the manifest but are not displayed in describe output—only the final computed values of `vars`, `env`, and `settings` are shown. + For example: diff --git a/website/docs/design-patterns/component-catalog/index.mdx b/website/docs/design-patterns/component-catalog/index.mdx index f30f65abb1..cca2403126 100644 --- a/website/docs/design-patterns/component-catalog/index.mdx +++ b/website/docs/design-patterns/component-catalog/index.mdx @@ -77,7 +77,7 @@ The **Configuration Catalog** pattern prescribes the following: For example, the `stacks/catalog/vpc` folder should mirror the `components/terraform/vpc` folder. - In the component's catalog folder, create `defaults.yaml` manifest with all the default values for the component (the defaults that can be reused - across multiple environments). Define all the required Atmos sections, e.g. `metadata`, `settings`, `vars`, `env`. + across multiple environments). Define all the required Atmos sections, e.g. `metadata`, `settings`, `vars`, `locals`, `env`. - In the component's catalog folder, add other manifests for different combinations of component configurations. We refer to them as archetype manifests. Each archetype can import the `defaults.yaml` file to reuse the default values and make the entire config diff --git a/website/docs/learn/yaml-guide.mdx b/website/docs/learn/yaml-guide.mdx index 070c04ebf8..ee47e00df1 100644 --- a/website/docs/learn/yaml-guide.mdx +++ b/website/docs/learn/yaml-guide.mdx @@ -230,7 +230,7 @@ components: :::tip -Dot notation works anywhere in Atmos YAML—not just in component configuration. Use it in `vars`, `settings`, `env`, or any nested section. +Dot notation works anywhere in Atmos YAML—not just in component configuration. Use it in `vars`, `locals`, `settings`, `env`, or any nested section. ::: ## Multi-line Strings: Folding and Literals diff --git a/website/docs/stacks/components/index.mdx b/website/docs/stacks/components/index.mdx index 36e4ab4602..12d412de58 100644 --- a/website/docs/stacks/components/index.mdx +++ b/website/docs/stacks/components/index.mdx @@ -41,6 +41,9 @@ All component types support these configuration sections:
[`vars`](/stacks/vars)
Variables passed to the component. Use [component validation](/validation/validating) to enforce policies.
+
[`locals`](/stacks/locals)
+
File-scoped temporary variables for DRY configuration within a single manifest.
+
[`env`](/stacks/env)
Environment variables set during execution.
diff --git a/website/docs/stacks/env.mdx b/website/docs/stacks/env.mdx index 6c9b7e4e18..c5c1d59dbf 100644 --- a/website/docs/stacks/env.mdx +++ b/website/docs/stacks/env.mdx @@ -299,6 +299,7 @@ env: ## Related - [Variables (vars)](/stacks/vars) +- [Locals](/stacks/locals) - [Settings](/stacks/settings) - [Authentication](/stacks/auth) - [Imports](/stacks/imports) diff --git a/website/docs/stacks/imports.mdx b/website/docs/stacks/imports.mdx index f425933e0b..4afa7b11b6 100644 --- a/website/docs/stacks/imports.mdx +++ b/website/docs/stacks/imports.mdx @@ -337,7 +337,7 @@ components: :::note -Since `Go` processes files ending in `.yaml.tmpl` text files with templates, we can parameterize the Atmos component name `eks-{{ .flavor }}/cluster` and any values in any sections (`vars`, `settings`, `env`, `backend`, etc.), and even the `import` section in the imported file (if the file imports other configurations). +Since `Go` processes files ending in `.yaml.tmpl` text files with templates, we can parameterize the Atmos component name `eks-{{ .flavor }}/cluster` and any values in any sections (`vars`, `locals`, `settings`, `env`, `backend`, etc.), and even the `import` section in the imported file (if the file imports other configurations). ::: diff --git a/website/docs/stacks/locals.mdx b/website/docs/stacks/locals.mdx new file mode 100644 index 0000000000..d32cd0fc54 --- /dev/null +++ b/website/docs/stacks/locals.mdx @@ -0,0 +1,367 @@ +--- +title: Configure Locals +sidebar_position: 12 +sidebar_label: locals +sidebar_class_name: command +description: Use the locals section to define file-scoped temporary variables that reduce repetition and improve readability. +id: locals +--- +import File from '@site/src/components/File' +import Intro from '@site/src/components/Intro' + + +The `locals` section defines file-scoped temporary variables for use within templates. Unlike `vars`, `settings`, and `env`, locals do **not** inherit across file boundaries—they are resolved within a single file and can reference each other with automatic dependency resolution. + + +## Use Cases + +- **Reduce Repetition:** Define common values once and reference them throughout the file. +- **Build Complex Values:** Construct naming conventions, tags, or resource identifiers from simpler components. +- **Improve Readability:** Give meaningful names to computed values instead of repeating expressions. +- **Template Composition:** Build values incrementally by referencing other locals. + +## How Locals Work + +Locals are similar to [Terraform locals](https://developer.hashicorp.com/terraform/language/values/locals) and [Terragrunt locals](https://terragrunt.gruntwork.io/docs/reference/config-blocks-and-attributes/#locals): + +1. **File-Scoped:** Locals are only available within the file where they are defined. They do not inherit across imports. +2. **Dependency Resolution:** Locals can reference other locals using `{{ .locals.name }}` syntax. Atmos automatically determines the correct resolution order. +3. **Cycle Detection:** Circular references are detected and reported with clear error messages. +4. **Template Support:** Locals support Go templates with [Sprig functions](http://masterminds.github.io/sprig/). + +## Configuration Scopes + +The `locals` section can be defined at multiple levels within a single file. Each scope inherits from its parent scope within that file. + +### Global Level + +Locals defined at the root level are available to all sections in the file: + +```yaml +# stacks/orgs/acme/plat/prod/us-east-1.yaml +locals: + namespace: acme + environment: prod + stage: us-east-1 + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" + +vars: + cluster_name: "{{ .locals.name_prefix }}-eks" +``` + +### Component-Type Level + +Locals defined under `terraform`, `helmfile`, or `packer` inherit from global locals and are available to all components of that type: + +```yaml +# stacks/orgs/acme/plat/prod/us-east-1.yaml +locals: + namespace: acme + environment: prod + +terraform: + locals: + # Inherits namespace and environment from global + backend_bucket: "{{ .locals.namespace }}-{{ .locals.environment }}-tfstate" + backend_key_prefix: "{{ .locals.environment }}" + + backend_type: s3 + backend: + s3: + bucket: "{{ .locals.backend_bucket }}" + key: "{{ .locals.backend_key_prefix }}/terraform.tfstate" +``` + +### Component Level + +Locals defined within a component inherit from the component-type scope: + +```yaml +# stacks/orgs/acme/plat/prod/us-east-1.yaml +locals: + namespace: acme + environment: prod + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" + +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" + + eks: + locals: + # Each component has its own locals scope + cluster_name: "{{ .locals.name_prefix }}-eks" + vars: + cluster_name: "{{ .locals.cluster_name }}" +``` + +## Scope Inheritance (Within a File) + +Within a single file, locals follow this inheritance chain: + +``` +Global locals + ↓ +Component-type locals (terraform/helmfile/packer) + ↓ +Component locals +``` + +Each level can: +- Access locals from parent scopes +- Define new locals +- Override parent locals with new values + +### Example + +```yaml +locals: + env: prod # Global + +terraform: + locals: + env: production # Overrides global for terraform components + tf_version: "1.5" + +components: + terraform: + vpc: + locals: + component: vpc + # Can access: env (= "production"), tf_version (= "1.5") + full_name: "{{ .locals.env }}-{{ .locals.component }}" +``` + +## File-Scoped Isolation + +**Important:** Unlike `vars`, `settings`, and `env`, locals do **not** inherit across file imports. Each file has its own isolated locals scope. + + +```yaml +# These locals are ONLY available in this file +locals: + default_region: us-east-1 + default_tags: + ManagedBy: Atmos +``` + + + +```yaml +import: + - catalog/defaults + +# The locals from catalog/defaults are NOT available here +# You must define your own locals in this file +locals: + namespace: acme + environment: prod +``` + + +This design is intentional: +- **Predictability:** Locals in a file only come from that file +- **No Hidden Dependencies:** You can understand a file without tracing imports +- **Flexibility:** Each file can define locals that make sense for its context + +## Dependency Resolution + +Locals can reference other locals, and Atmos automatically resolves them in the correct order using topological sorting: + +```yaml +locals: + # These can be defined in any order + full_name: "{{ .locals.name_prefix }}-{{ .locals.component }}" + name_prefix: "{{ .locals.namespace }}-{{ .locals.environment }}" + namespace: acme + environment: prod + component: vpc +``` + +Atmos resolves these in dependency order: +1. `namespace` and `environment` (no dependencies) +2. `component` (no dependencies) +3. `name_prefix` (depends on `namespace`, `environment`) +4. `full_name` (depends on `name_prefix`, `component`) + +## Circular Dependency Detection + +Atmos detects circular references and provides clear error messages: + +```yaml +locals: + a: "{{ .locals.b }}" + b: "{{ .locals.c }}" + c: "{{ .locals.a }}" # Creates a cycle! +``` + +Error output: +``` +circular dependency in locals at stacks/example.yaml + +Dependency cycle detected: + a → b → c → a +``` + +## Using Templates in Locals + +Locals support full Go template syntax with [Sprig functions](http://masterminds.github.io/sprig/): + +```yaml +locals: + name: myapp + environment: production + + # String manipulation + upper_name: "{{ .locals.name | upper }}" + quoted_env: '{{ .locals.environment | quote }}' + + # Conditionals + log_level: '{{ if eq .locals.environment "production" }}warn{{ else }}debug{{ end }}' + + # Complex expressions + resource_name: "{{ .locals.name }}-{{ .locals.environment | lower | trunc 4 }}" +``` + +## Complex Values + +Locals can contain maps and lists, not just strings: + +```yaml +locals: + namespace: acme + environment: prod + + # Map value + default_tags: + Namespace: "{{ .locals.namespace }}" + Environment: "{{ .locals.environment }}" + ManagedBy: Atmos + + # List value + availability_zones: + - us-east-1a + - us-east-1b + - us-east-1c + + # Nested structure + backend_config: + bucket: "{{ .locals.namespace }}-tfstate" + region: us-east-1 + encrypt: true + +vars: + tags: "{{ .locals.default_tags }}" + azs: "{{ .locals.availability_zones }}" +``` + +## Complete Example + + +```yaml +# Global locals - available throughout this file +locals: + # Base identifiers + namespace: acme + tenant: platform + environment: prod + stage: us-east-1 + + # Computed values + name_prefix: "{{ .locals.namespace }}-{{ .locals.tenant }}-{{ .locals.environment }}" + full_name: "{{ .locals.name_prefix }}-{{ .locals.stage }}" + + # Shared tags + default_tags: + Namespace: "{{ .locals.namespace }}" + Tenant: "{{ .locals.tenant }}" + Environment: "{{ .locals.environment }}" + Stage: "{{ .locals.stage }}" + ManagedBy: Atmos + +# Use locals in global vars +vars: + region: "{{ .locals.stage }}" + tags: "{{ .locals.default_tags }}" + +terraform: + # Terraform-specific locals + locals: + backend_bucket: "{{ .locals.namespace }}-{{ .locals.environment }}-tfstate" + + backend_type: s3 + backend: + s3: + bucket: "{{ .locals.backend_bucket }}" + region: "{{ .locals.stage }}" + key: terraform.tfstate + +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" + tags: + Name: "{{ .locals.vpc_name }}" + + eks: + locals: + cluster_name: "{{ .locals.full_name }}-eks" + vars: + cluster_name: "{{ .locals.cluster_name }}" + tags: + Name: "{{ .locals.cluster_name }}" +``` + + +## Locals vs Vars + +| Aspect | `locals` | `vars` | +|--------|----------|--------| +| **Scope** | File-scoped only | Inherits across imports | +| **Purpose** | Temporary values for DRY | Input variables for components | +| **Output** | Not passed to components | Passed to Terraform/Helmfile/Packer | +| **Dependencies** | Can reference other locals | Can reference locals | +| **Visibility** | Internal to stack config | Visible in component execution | + +Use `locals` for intermediate computations and `vars` for values that need to be passed to your components. + +## Best Practices + +1. **Use for Repetition:** If you find yourself repeating the same value or expression, extract it to a local. + +2. **Build Incrementally:** Start with simple locals and compose them into more complex values: + ```yaml + locals: + namespace: acme + env: prod + prefix: "{{ .locals.namespace }}-{{ .locals.env }}" # Build on simpler locals + bucket: "{{ .locals.prefix }}-assets" # Build on prefix + ``` + +3. **Keep Locals Close:** Define locals near where they're used. If a local is only used in one component, define it at the component level. + +4. **Use Descriptive Names:** Choose names that describe what the value represents, not how it's computed. + +5. **Avoid Deep Nesting:** If you have many levels of local dependencies, consider simplifying or restructuring. + +6. **Remember File Scope:** Don't expect locals from imported files—define what you need in each file. + +## Related + +- [Variables (vars)](/stacks/vars) +- [Environment Variables (env)](/stacks/env) +- [Settings](/stacks/settings) +- [Imports](/stacks/imports) +- [YAML Functions](/functions/yaml) diff --git a/website/docs/stacks/overrides.mdx b/website/docs/stacks/overrides.mdx index c4d585b62d..7c3e322e06 100644 --- a/website/docs/stacks/overrides.mdx +++ b/website/docs/stacks/overrides.mdx @@ -88,6 +88,8 @@ section at the global, Terraform or Helmfile levels, all the components in the t the other hand, if we define an `overrides` section in a stack manifest, only the components directly defined in the manifest and its imports will get the overridden values, not all the components in the top-level Atmos stack. +Note: The [`locals`](/stacks/locals) section is also file-scoped and does not inherit across imports, similar to `overrides`. + This is especially useful when you have Atmos stack manifests split per Teams. Each Team manages a set of components, and you need to define a common configuration (or override the existing one) for the components that only a particular Team manages. diff --git a/website/docs/stacks/settings/index.mdx b/website/docs/stacks/settings/index.mdx index 4c61b5ab0c..fe889234d8 100644 --- a/website/docs/stacks/settings/index.mdx +++ b/website/docs/stacks/settings/index.mdx @@ -255,6 +255,7 @@ settings: ## Related - [Variables (vars)](/stacks/vars) +- [Locals](/stacks/locals) - [Environment Variables (env)](/stacks/env) - [depends_on](/stacks/settings/depends_on) - [Imports](/stacks/imports) diff --git a/website/docs/stacks/stacks.mdx b/website/docs/stacks/stacks.mdx index e90189c385..ea4913f9f0 100644 --- a/website/docs/stacks/stacks.mdx +++ b/website/docs/stacks/stacks.mdx @@ -22,6 +22,7 @@ Stack manifests support various configuration sections at different scopes: |---------|-------------|--------| | [name](/stacks/name) | Explicit stack name override | Stack manifest only | | [vars](/stacks/vars) | Variables passed to components | Global, component-type, component | +| [locals](/stacks/locals) | File-scoped temporary variables | Global, component-type, component | | [env](/stacks/env) | Environment variables | Global, component-type, component | | [settings](/stacks/settings) | Integrations and metadata | Global, component-type, component | | [metadata](/stacks/components/component-metadata) | Component behavior and inheritance | Component only | diff --git a/website/docs/stacks/vars.mdx b/website/docs/stacks/vars.mdx index faa280e679..3d4df0636d 100644 --- a/website/docs/stacks/vars.mdx +++ b/website/docs/stacks/vars.mdx @@ -257,6 +257,7 @@ For the complete list of YAML functions, see [YAML Functions](/functions/yaml). ## Related +- [Locals](/stacks/locals) - [Environment Variables (env)](/stacks/env) - [Settings](/stacks/settings) - [Imports](/stacks/imports) 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 7df8f7b3d7..f6c5eb3b0f 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 @@ -28,6 +28,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "components": { "$ref": "#/definitions/components" }, @@ -164,7 +167,7 @@ }, { "type": "object", - "additionalProperties": false, + "additionalProperties": true, "properties": { "terraform": { "$ref": "#/definitions/terraform_components" @@ -204,6 +207,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -276,6 +282,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -323,6 +332,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -380,6 +392,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -409,6 +424,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" }, @@ -466,6 +484,9 @@ "settings": { "$ref": "#/definitions/settings" }, + "locals": { + "$ref": "#/definitions/locals" + }, "command": { "$ref": "#/definitions/command" } @@ -503,17 +524,13 @@ "real" ] }, - "name": { - "type": "string", - "description": "Logical component identity used for workspace key prefix auto-generation. Inheritable from base components." - }, "enabled": { "type": "boolean", "description": "Flag to enable or disable the component" }, "component": { "type": "string", - "description": "Terraform/OpenTofu/Helmfile/Packer component" + "description": "Terraform/OpenTofu/Helmfile component" }, "inherits": { "oneOf": [ @@ -547,7 +564,7 @@ }, { "type": "object", - "description": "Custom configuration per component. Inherited when stacks.inherit.metadata is enabled (default).", + "description": "Custom configuration per component, not inherited by derived components", "additionalProperties": true, "title": "custom" } @@ -689,6 +706,20 @@ } ] }, + "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.", + "oneOf": [ + { + "type": "string", + "pattern": "^!include" + }, + { + "type": "object", + "additionalProperties": true + } + ] + }, "backend_type": { "title": "backend_type", "description": "Backend type", @@ -920,9 +951,6 @@ { "type": "object", "properties": { - "stack": { - "type": "string" - }, "namespace": { "type": "string" },