Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
109 changes: 109 additions & 0 deletions internal/exec/stack_processor_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,3 +450,112 @@ components:
assert.Equal(t, "3", vars2["computed"])
assert.Equal(t, 10, vars2["value"])
}

// TestGlobalIgnoreMissingTemplateValues tests that the global templates.settings.ignore_missing_template_values
// setting in atmos.yaml is used as a fallback when per-import ignore_missing_template_values is not set.
func TestGlobalIgnoreMissingTemplateValues(t *testing.T) {
tempDir := t.TempDir()

// Create a main stack file that imports a catalog file with context but missing template vars.
mainStack := `
import:
- path: catalog/component.yaml
context:
flavor: blue
`

// The catalog file uses a template variable {{ .undeclared_var }} which is not in the context.
catalogFile := `
components:
terraform:
"{{ .flavor }}/cluster":
vars:
flavor: "{{ .flavor }}"
extra: "{{ .undeclared_var }}"
`

// Write test files.
err := os.MkdirAll(filepath.Join(tempDir, "catalog"), 0o755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(tempDir, "stack.yaml"), []byte(mainStack), 0o644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(tempDir, "catalog", "component.yaml"), []byte(catalogFile), 0o644)
require.NoError(t, err)

// Test 1: Without the global setting, missing template values should cause an error.
atmosConfigNoIgnore := &schema.AtmosConfiguration{
BasePath: tempDir,
StacksBaseAbsolutePath: tempDir,
Templates: schema.Templates{
Settings: schema.TemplatesSettings{
Enabled: true,
IgnoreMissingTemplateValues: false,
},
},
Logs: schema.Logs{Level: "Info"},
}

stackPath := filepath.Join(tempDir, "stack.yaml")
_, _, _, _, _, _, _, _, err = ProcessYAMLConfigFileWithContext( //nolint:dogsled
atmosConfigNoIgnore,
tempDir,
stackPath,
map[string]map[string]any{},
nil,
false, // ignoreMissingFiles
false, // skipTemplatesProcessingInImports
false, // ignoreMissingTemplateValues (import-level)
false, // skipIfMissing
nil,
nil,
nil,
nil,
"",
nil,
)
assert.Error(t, err, "expected an error when ignore_missing_template_values is false and template vars are missing")

// Test 2: With the global setting enabled, missing template values should not cause an error.
atmosConfigWithIgnore := &schema.AtmosConfiguration{
BasePath: tempDir,
StacksBaseAbsolutePath: tempDir,
Templates: schema.Templates{
Settings: schema.TemplatesSettings{
Enabled: true,
IgnoreMissingTemplateValues: true, // Global setting.
},
},
Logs: schema.Logs{Level: "Info"},
}

result, _, _, _, _, _, _, _, err := ProcessYAMLConfigFileWithContext( //nolint:dogsled
atmosConfigWithIgnore,
tempDir,
stackPath,
map[string]map[string]any{},
nil,
false, // ignoreMissingFiles
false, // skipTemplatesProcessingInImports
false, // ignoreMissingTemplateValues (import-level, not set; global should apply)
false, // skipIfMissing
nil,
nil,
nil,
nil,
"",
nil,
)
require.NoError(t, err, "expected no error when global ignore_missing_template_values is true")
require.NotNil(t, result)

// Verify the component was created with the available template values.
components, ok := result["components"].(map[string]any)
require.True(t, ok)
terraform, ok := components["terraform"].(map[string]any)
require.True(t, ok)
cluster, ok := terraform["blue/cluster"].(map[string]any)
require.True(t, ok)
vars, ok := cluster["vars"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "blue", vars["flavor"])
}
4 changes: 2 additions & 2 deletions internal/exec/stack_processor_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ func ProcessYAMLConfigFiles(
nil,
ignoreMissingFiles,
false,
false,
atmosConfig != nil && atmosConfig.Templates.Settings.IgnoreMissingTemplateValues,
false,
map[string]any{},
map[string]any{},
Expand Down Expand Up @@ -1087,7 +1087,7 @@ func processYAMLConfigFileWithContextInternal(
mergedContext,
ignoreMissingFiles,
importStruct.SkipTemplatesProcessing,
importStruct.IgnoreMissingTemplateValues,
importStruct.IgnoreMissingTemplateValues || (atmosConfig != nil && atmosConfig.Templates.Settings.IgnoreMissingTemplateValues),
importStruct.SkipIfMissing,
parentTerraformOverridesInline,
parentTerraformOverridesImports,
Expand Down
2 changes: 1 addition & 1 deletion internal/exec/terraform_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func ExecuteTerraformQuery(info *schema.ConfigAndStacksInfo) error {
processedCount := 0

err = walkTerraformComponents(stacks, func(stackName, componentName string, componentSection map[string]any) error {
processed, err := processTerraformComponent(&atmosConfig, info, stackName, componentName, componentSection, logFunc)
processed, err := processTerraformComponent(&atmosConfig, info, stackName, componentName, componentSection, logFunc, ExecuteTerraform)
if processed {
processedCount++
}
Expand Down
4 changes: 3 additions & 1 deletion internal/exec/terraform_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,12 +285,14 @@ func walkTerraformComponents(

// processTerraformComponent performs filtering and execution logic for a single Terraform component.
// Returns true if the component was processed (passed all filters), false otherwise.
// The executeFn parameter allows callers to inject a custom executor (used for testing without gomonkey).
func processTerraformComponent(
atmosConfig *schema.AtmosConfiguration,
info *schema.ConfigAndStacksInfo,
stackName, componentName string,
componentSection map[string]any,
logFunc func(msg any, keyvals ...any),
executeFn func(schema.ConfigAndStacksInfo) error,
) (bool, error) {
metadataSection, ok := componentSection[cfg.MetadataSectionName].(map[string]any)
if !ok {
Expand Down Expand Up @@ -334,7 +336,7 @@ func processTerraformComponent(
info.Stack = stackName
info.StackFromArg = stackName

if err := ExecuteTerraform(*info); err != nil {
if err := executeFn(*info); err != nil {
return true, err
}

Expand Down
86 changes: 33 additions & 53 deletions internal/exec/terraform_utils_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package exec

import (
"errors"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/agiledragon/gomonkey/v2"
Expand Down Expand Up @@ -376,20 +378,23 @@ func TestProcessTerraformComponent(t *testing.T) {
}
}

// mockExecutor returns a mock executor that records whether it was called.
mockExecutor := func(called *bool, returnErr error) func(schema.ConfigAndStacksInfo) error {
return func(i schema.ConfigAndStacksInfo) error {
*called = true
return returnErr
}
}

t.Run("no metadata section", func(t *testing.T) {
// Section without metadata should return false, nil.
section := map[string]any{
"vars": map[string]any{"key": "value"},
}
called := false
patch := gomonkey.ApplyFunc(ExecuteTerraform, func(i schema.ConfigAndStacksInfo) error {
called = true
return nil
})
defer patch.Reset()

info := schema.ConfigAndStacksInfo{SubCommand: "plan"}
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc)
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc, mockExecutor(&called, nil))
assert.NoError(t, err)
assert.False(t, processed)
assert.False(t, called)
Expand All @@ -402,14 +407,9 @@ func TestProcessTerraformComponent(t *testing.T) {
"vars": map[string]any{"key": "value"},
}
called := false
patch := gomonkey.ApplyFunc(ExecuteTerraform, func(i schema.ConfigAndStacksInfo) error {
called = true
return nil
})
defer patch.Reset()

info := schema.ConfigAndStacksInfo{SubCommand: "plan"}
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc)
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc, mockExecutor(&called, nil))
assert.NoError(t, err)
assert.False(t, processed)
assert.False(t, called)
Expand All @@ -418,14 +418,9 @@ func TestProcessTerraformComponent(t *testing.T) {
t.Run("abstract", func(t *testing.T) {
section := newSection(map[string]any{"type": "abstract"})
called := false
patch := gomonkey.ApplyFunc(ExecuteTerraform, func(i schema.ConfigAndStacksInfo) error {
called = true
return nil
})
defer patch.Reset()

info := schema.ConfigAndStacksInfo{SubCommand: "plan"}
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc)
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc, mockExecutor(&called, nil))
assert.NoError(t, err)
assert.False(t, processed)
assert.False(t, called)
Expand All @@ -434,14 +429,9 @@ func TestProcessTerraformComponent(t *testing.T) {
t.Run("disabled", func(t *testing.T) {
section := newSection(map[string]any{"enabled": false})
called := false
patch := gomonkey.ApplyFunc(ExecuteTerraform, func(i schema.ConfigAndStacksInfo) error {
called = true
return nil
})
defer patch.Reset()

info := schema.ConfigAndStacksInfo{SubCommand: "plan"}
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc)
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc, mockExecutor(&called, nil))
assert.NoError(t, err)
assert.False(t, processed)
assert.False(t, called)
Expand All @@ -450,14 +440,9 @@ func TestProcessTerraformComponent(t *testing.T) {
t.Run("query not satisfied", func(t *testing.T) {
section := newSection(map[string]any{"enabled": true})
called := false
patch := gomonkey.ApplyFunc(ExecuteTerraform, func(i schema.ConfigAndStacksInfo) error {
called = true
return nil
})
defer patch.Reset()

info := schema.ConfigAndStacksInfo{SubCommand: "plan", Query: ".vars.tags.team == \"foo\""}
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc)
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc, mockExecutor(&called, nil))
assert.NoError(t, err)
assert.False(t, processed)
assert.False(t, called)
Expand All @@ -466,17 +451,16 @@ func TestProcessTerraformComponent(t *testing.T) {
t.Run("execute", func(t *testing.T) {
section := newSection(map[string]any{"enabled": true})
called := false
patch := gomonkey.ApplyFunc(ExecuteTerraform, func(i schema.ConfigAndStacksInfo) error {
executor := func(i schema.ConfigAndStacksInfo) error {
called = true
// check fields set
assert.Equal(t, component, i.Component)
assert.Equal(t, stack, i.Stack)
return nil
})
defer patch.Reset()
}

info := schema.ConfigAndStacksInfo{SubCommand: "plan"}
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc)
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc, executor)
assert.NoError(t, err)
assert.True(t, processed)
assert.True(t, called)
Expand All @@ -485,36 +469,21 @@ func TestProcessTerraformComponent(t *testing.T) {
t.Run("dry run", func(t *testing.T) {
section := newSection(map[string]any{"enabled": true})
called := false
patch := gomonkey.ApplyFunc(ExecuteTerraform, func(i schema.ConfigAndStacksInfo) error {
called = true
return nil
})
defer patch.Reset()

info := schema.ConfigAndStacksInfo{SubCommand: "plan", DryRun: true}
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc)
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc, mockExecutor(&called, nil))
assert.NoError(t, err)
assert.True(t, processed) // Returns true in dry-run mode.
assert.False(t, called) // But doesn't call ExecuteTerraform.
assert.False(t, called) // But doesn't call executeFn.
})

t.Run("execute returns error", func(t *testing.T) {
section := newSection(map[string]any{"enabled": true})
expectedErr := assert.AnError
expectedErr := errors.New("terraform error")
called := false
patch := gomonkey.ApplyFunc(ExecuteTerraform, func(i schema.ConfigAndStacksInfo) error {
called = true
return expectedErr
})
defer patch.Reset()

info := schema.ConfigAndStacksInfo{SubCommand: "plan"}
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc)

// If gomonkey didn't work (common on macOS), skip the test.
if !called {
t.Skip("gomonkey function mocking failed (likely due to platform issues)")
}
processed, err := processTerraformComponent(&atmosConfig, &info, stack, component, section, logFunc, mockExecutor(&called, expectedErr))

assert.Error(t, err)
assert.True(t, processed)
Expand Down Expand Up @@ -1107,6 +1076,12 @@ func BenchmarkNeedProcessTemplatesAndYamlFunctions(b *testing.B) {
}

func TestExecuteTerraformAffectedComponentInDepOrder(t *testing.T) {
// gomonkey uses unsafe binary patching that causes a fatal SIGBUS on macOS ARM64
// (Apple Silicon) because code pages are read-only. Skip on that platform.
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
t.Skip("gomonkey binary patching is not supported on macOS ARM64")
}

tests := []struct {
name string
info *schema.ConfigAndStacksInfo
Expand Down Expand Up @@ -1432,6 +1407,11 @@ func BenchmarkParseUploadStatusFlag(b *testing.B) {
}

func BenchmarkExecuteTerraformAffectedComponentInDepOrder(b *testing.B) {
// gomonkey uses unsafe binary patching that causes a fatal SIGBUS on macOS ARM64.
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
b.Skip("gomonkey binary patching is not supported on macOS ARM64")
}

info := &schema.ConfigAndStacksInfo{
SubCommand: "plan",
DryRun: true, // Use dry run to avoid actual terraform execution.
Expand Down
4 changes: 2 additions & 2 deletions internal/exec/validate_stacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func ValidateStacks(atmosConfig *schema.AtmosConfiguration) error {
nil,
true, // ignoreMissingFiles for first pass
false,
false,
atmosConfig.Templates.Settings.IgnoreMissingTemplateValues,
false,
map[string]any{},
map[string]any{},
Expand Down Expand Up @@ -222,7 +222,7 @@ func ValidateStacks(atmosConfig *schema.AtmosConfiguration) error {
nil,
false,
false,
false,
atmosConfig.Templates.Settings.IgnoreMissingTemplateValues,
false,
map[string]any{},
map[string]any{},
Expand Down
4 changes: 4 additions & 0 deletions pkg/schema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,10 @@ type TemplatesSettings struct {
Delimiters []string `yaml:"delimiters,omitempty" json:"delimiters,omitempty" mapstructure:"delimiters"`
Evaluations int `yaml:"evaluations,omitempty" json:"evaluations,omitempty" mapstructure:"evaluations"`
Env map[string]string `yaml:"env,omitempty" json:"env,omitempty" mapstructure:"-"` // mapstructure:"-" avoids collision with Command.Env []CommandEnv.
// IgnoreMissingTemplateValues is the global default for ignoring missing template values.
// When true, template processing will not fail if a template variable is missing.
// This can be overridden per-import using the import's own ignore_missing_template_values setting.
IgnoreMissingTemplateValues bool `yaml:"ignore_missing_template_values,omitempty" json:"ignore_missing_template_values,omitempty" mapstructure:"ignore_missing_template_values"`
}

type TemplatesSettingsSprig struct {
Expand Down
Loading