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
2 changes: 1 addition & 1 deletion internal/exec/yaml_func_resolution_context_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func BenchmarkProcessCustomYamlTagsOverhead(b *testing.B) {
b.Run("WithoutCycleDetection", func(b *testing.B) {
// Simulate old behavior (direct processing without context).
for i := 0; i < b.N; i++ {
result := processNodes(atmosConfig, input, "test-stack", nil, nil)
result, _ := processNodes(atmosConfig, input, "test-stack", nil, nil)
_ = result
}
})
Expand Down
104 changes: 42 additions & 62 deletions internal/exec/yaml_func_terraform_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package exec

import (
"fmt"
"strings"

errUtils "github.com/cloudposse/atmos/errors"
fn "github.com/cloudposse/atmos/pkg/function"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
Expand All @@ -19,85 +19,66 @@ func processTagTerraformOutput(
input string,
currentStack string,
stackInfo *schema.ConfigAndStacksInfo,
) any {
) (any, error) {
return processTagTerraformOutputWithContext(atmosConfig, input, currentStack, nil, stackInfo)
}

// trackOutputDependency records the dependency in the resolution context and returns a cleanup function.
func trackOutputDependency(
atmosConfig *schema.AtmosConfiguration,
resolutionCtx *ResolutionContext,
component string,
stack string,
input string,
) func() {
if resolutionCtx == nil {
return func() {}
}

node := DependencyNode{
Component: component,
Stack: stack,
FunctionType: "terraform.output",
FunctionCall: input,
}

// Check and record this dependency.
if err := resolutionCtx.Push(atmosConfig, node); err != nil {
errUtils.CheckErrorPrintAndExit(err, "", "")
}

// Return cleanup function.
return func() { resolutionCtx.Pop(atmosConfig) }
}

// processTagTerraformOutputWithContext processes `!terraform.output` YAML tag with cycle detection.
func processTagTerraformOutputWithContext(
atmosConfig *schema.AtmosConfiguration,
input string,
currentStack string,
resolutionCtx *ResolutionContext,
stackInfo *schema.ConfigAndStacksInfo,
) any {
) (any, error) {
defer perf.Track(atmosConfig, "exec.processTagTerraformOutputWithContext")()

log.Debug("Executing Atmos YAML function", "function", input)

str, err := getStringAfterTag(input, u.AtmosYamlFuncTerraformOutput)
errUtils.CheckErrorPrintAndExit(err, "", "")

var component string
var stack string
var output string

// Split the string into slices based on any whitespace (one or more spaces, tabs, or newlines),
// while also ignoring leading and trailing whitespace.
// SplitStringByDelimiter splits a string by the delimiter, not splitting inside quotes.
parts, err := u.SplitStringByDelimiter(str, ' ')
errUtils.CheckErrorPrintAndExit(err, "", "")

partsLen := len(parts)

switch partsLen {
case 3:
component = strings.TrimSpace(parts[0])
stack = strings.TrimSpace(parts[1])
output = strings.TrimSpace(parts[2])
case 2:
component = strings.TrimSpace(parts[0])
if err != nil {
return nil, err
}

// Parse function arguments using the purpose-built parser.
// Format: component [stack] expression
// Stack is optional - if not provided, uses currentStack.
component, stack, output := fn.ParseArgs(str)

if component == "" {
return nil, fmt.Errorf("%w: missing component: %s", errUtils.ErrYamlFuncInvalidArguments, input)
}

if output == "" {
return nil, fmt.Errorf("%w: missing output expression: %s", errUtils.ErrYamlFuncInvalidArguments, input)
}

// If no stack was specified, use the current stack.
if stack == "" {
stack = currentStack
output = strings.TrimSpace(parts[1])
log.Debug("Executing Atmos YAML function with component and output parameters; using current stack",
"function", input,
"stack", currentStack,
)
default:
er := fmt.Errorf("%w %s", errUtils.ErrYamlFuncInvalidArguments, input)
errUtils.CheckErrorPrintAndExit(er, "", "")
}

// Track dependency and defer cleanup.
defer trackOutputDependency(atmosConfig, resolutionCtx, component, stack, input)()
// Check for circular dependencies if resolution context is provided.
if resolutionCtx != nil {
node := DependencyNode{
Component: component,
Stack: stack,
FunctionType: "terraform.output",
FunctionCall: input,
}

// Check and record this dependency.
if err := resolutionCtx.Push(atmosConfig, node); err != nil {
return nil, err
}

// Defer pop to ensure we clean up even if there's an error.
defer resolutionCtx.Pop(atmosConfig)
}

// Extract authContext and authManager from stackInfo if available.
var authContext *schema.AuthContext
Expand All @@ -109,17 +90,16 @@ func processTagTerraformOutputWithContext(

value, exists, err := outputGetter.GetOutput(atmosConfig, stack, component, output, false, authContext, authManager)
if err != nil {
er := fmt.Errorf("failed to get terraform output for component %s in stack %s, output %s: %w", component, stack, output, err)
errUtils.CheckErrorPrintAndExit(er, "", "")
return nil, fmt.Errorf("failed to get terraform output for component %s in stack %s, output %s: %w", component, stack, output, err)
}

// If the output doesn't exist, return nil (backward compatible).
// This allows YAML functions to reference outputs that don't exist yet.
// Use yq fallback syntax (.output // "default") for default values.
if !exists {
return nil
return nil, nil
}

// value may be nil here if the terraform output is legitimately null, which is valid.
return value
return value, nil
}
18 changes: 12 additions & 6 deletions internal/exec/yaml_func_terraform_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,16 @@ func TestYamlFuncTerraformOutput(t *testing.T) {
atmosConfig, err := cfg.InitCliConfig(info, true)
assert.NoError(t, err)

d := processTagTerraformOutput(&atmosConfig, "!terraform.output component-1 foo", stack, nil)
d, err := processTagTerraformOutput(&atmosConfig, "!terraform.output component-1 foo", stack, nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-a", d)

d = processTagTerraformOutput(&atmosConfig, "!terraform.output component-1 bar", stack, nil)
d, err = processTagTerraformOutput(&atmosConfig, "!terraform.output component-1 bar", stack, nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-b", d)

d = processTagTerraformOutput(&atmosConfig, "!terraform.output component-1 nonprod baz", "", nil)
d, err = processTagTerraformOutput(&atmosConfig, "!terraform.output component-1 nonprod baz", "", nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-c", d)

res, err := ExecuteDescribeComponent(&ExecuteDescribeComponentParams{
Expand Down Expand Up @@ -102,13 +105,16 @@ func TestYamlFuncTerraformOutput(t *testing.T) {
t.Fatalf("Failed to execute 'ExecuteTerraform': %v", err)
}

d = processTagTerraformOutput(&atmosConfig, "!terraform.output component-2 foo", stack, nil)
d, err = processTagTerraformOutput(&atmosConfig, "!terraform.output component-2 foo", stack, nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-a", d)

d = processTagTerraformOutput(&atmosConfig, "!terraform.output component-2 nonprod bar", stack, nil)
d, err = processTagTerraformOutput(&atmosConfig, "!terraform.output component-2 nonprod bar", stack, nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-b", d)

d = processTagTerraformOutput(&atmosConfig, "!terraform.output component-2 nonprod baz", "", nil)
d, err = processTagTerraformOutput(&atmosConfig, "!terraform.output component-2 nonprod baz", "", nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-c", d)

res, err = ExecuteDescribeComponent(&ExecuteDescribeComponentParams{
Expand Down
50 changes: 23 additions & 27 deletions internal/exec/yaml_func_terraform_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package exec

import (
"fmt"
"strings"

errUtils "github.com/cloudposse/atmos/errors"
fn "github.com/cloudposse/atmos/pkg/function"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
Expand All @@ -19,7 +19,7 @@ func processTagTerraformState(
input string,
currentStack string,
stackInfo *schema.ConfigAndStacksInfo,
) any {
) (any, error) {
return processTagTerraformStateWithContext(atmosConfig, input, currentStack, nil, stackInfo)
}

Expand All @@ -30,42 +30,36 @@ func processTagTerraformStateWithContext(
currentStack string,
resolutionCtx *ResolutionContext,
stackInfo *schema.ConfigAndStacksInfo,
) any {
) (any, error) {
defer perf.Track(atmosConfig, "exec.processTagTerraformStateWithContext")()

log.Debug("Executing Atmos YAML function", "function", input)

str, err := getStringAfterTag(input, u.AtmosYamlFuncTerraformState)
errUtils.CheckErrorPrintAndExit(err, "", "")
if err != nil {
return nil, err
}

var component string
var stack string
var output string
// Parse function arguments using the purpose-built parser.
// Format: component [stack] expression
// Stack is optional - if not provided, uses currentStack.
component, stack, output := fn.ParseArgs(str)

// Split the string into slices based on any whitespace (one or more spaces, tabs, or newlines),
// while also ignoring leading and trailing whitespace.
// SplitStringByDelimiter splits a string by the delimiter, not splitting inside quotes.
parts, err := u.SplitStringByDelimiter(str, ' ')
errUtils.CheckErrorPrintAndExit(err, "", "")
if component == "" {
return nil, fmt.Errorf("%w: missing component: %s", errUtils.ErrYamlFuncInvalidArguments, input)
}

partsLen := len(parts)
if output == "" {
return nil, fmt.Errorf("%w: missing output expression: %s", errUtils.ErrYamlFuncInvalidArguments, input)
}

switch partsLen {
case 3:
component = strings.TrimSpace(parts[0])
stack = strings.TrimSpace(parts[1])
output = strings.TrimSpace(parts[2])
case 2:
component = strings.TrimSpace(parts[0])
// If no stack was specified, use the current stack.
if stack == "" {
stack = currentStack
output = strings.TrimSpace(parts[1])
log.Debug("Executing Atmos YAML function with component and output parameters; using current stack",
"function", input,
"stack", currentStack,
)
default:
er := fmt.Errorf("%w %s", errUtils.ErrYamlFuncInvalidArguments, input)
errUtils.CheckErrorPrintAndExit(er, "", "")
}

// Check for circular dependencies if resolution context is provided.
Expand All @@ -79,7 +73,7 @@ func processTagTerraformStateWithContext(

// Check and record this dependency.
if err := resolutionCtx.Push(atmosConfig, node); err != nil {
errUtils.CheckErrorPrintAndExit(err, "", "")
return nil, err
}

// Defer pop to ensure we clean up even if there's an error.
Expand All @@ -95,6 +89,8 @@ func processTagTerraformStateWithContext(
}

value, err := stateGetter.GetState(atmosConfig, input, stack, component, output, false, authContext, authManager)
errUtils.CheckErrorPrintAndExit(err, "", "")
return value
if err != nil {
return nil, err
}
return value, nil
}
18 changes: 12 additions & 6 deletions internal/exec/yaml_func_terraform_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,16 @@ func TestYamlFuncTerraformState(t *testing.T) {
atmosConfig, err := cfg.InitCliConfig(info, true)
assert.NoError(t, err)

d := processTagTerraformState(&atmosConfig, "!terraform.state component-1 foo", stack, nil)
d, err := processTagTerraformState(&atmosConfig, "!terraform.state component-1 foo", stack, nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-a", d)

d = processTagTerraformState(&atmosConfig, "!terraform.state component-1 bar", stack, nil)
d, err = processTagTerraformState(&atmosConfig, "!terraform.state component-1 bar", stack, nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-b", d)

d = processTagTerraformState(&atmosConfig, "!terraform.state component-1 nonprod baz", "", nil)
d, err = processTagTerraformState(&atmosConfig, "!terraform.state component-1 nonprod baz", "", nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-c", d)

res, err := ExecuteDescribeComponent(&ExecuteDescribeComponentParams{
Expand Down Expand Up @@ -102,13 +105,16 @@ func TestYamlFuncTerraformState(t *testing.T) {
t.Fatalf("Failed to execute 'ExecuteTerraform': %v", err)
}

d = processTagTerraformState(&atmosConfig, "!terraform.state component-2 foo", stack, nil)
d, err = processTagTerraformState(&atmosConfig, "!terraform.state component-2 foo", stack, nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-a", d)

d = processTagTerraformState(&atmosConfig, "!terraform.state component-2 nonprod bar", stack, nil)
d, err = processTagTerraformState(&atmosConfig, "!terraform.state component-2 nonprod bar", stack, nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-b", d)

d = processTagTerraformState(&atmosConfig, "!terraform.state component-2 nonprod baz", "", nil)
d, err = processTagTerraformState(&atmosConfig, "!terraform.state component-2 nonprod baz", "", nil)
assert.NoError(t, err)
assert.Equal(t, "component-1-c", d)

res, err = ExecuteDescribeComponent(&ExecuteDescribeComponentParams{
Expand Down
Loading
Loading