Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,856 changes: 1,856 additions & 0 deletions docs/prd/file-scoped-locals.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
210 changes: 210 additions & 0 deletions internal/exec/stack_processor_locals.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading