From 7170c47b72f0281322771dbd4f7efbe7e42474b6 Mon Sep 17 00:00:00 2001 From: Matej Risek Date: Mon, 8 Dec 2025 15:52:23 +0100 Subject: [PATCH] Add local values walk to walk_dynamic This helps us catch diagnostics in locals that only happen during evaluation Co-authored-by: Mutahhir Hayat Co-authored-by: Matej Risek --- .../internal/stackeval/walk_dynamic.go | 4 +- internal/stacks/stackruntime/plan_test.go | 73 +++++++++++++++++++ .../test/invalid-local/invalid-local.tf | 7 ++ .../invalid-local.tfcomponent.hcl | 31 ++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/invalid-local/invalid-local.tf create mode 100644 internal/stacks/stackruntime/testdata/mainbundle/test/invalid-local/invalid-local.tfcomponent.hcl diff --git a/internal/stacks/stackruntime/internal/stackeval/walk_dynamic.go b/internal/stacks/stackruntime/internal/stackeval/walk_dynamic.go index b63233eecb..a9aced7b6d 100644 --- a/internal/stacks/stackruntime/internal/stackeval/walk_dynamic.go +++ b/internal/stacks/stackruntime/internal/stackeval/walk_dynamic.go @@ -101,7 +101,9 @@ func walkDynamicObjectsInStack[Output any]( for _, variable := range stack.InputVariables() { visit(ctx, walk, variable) } - // TODO: Local values + for _, localValue := range stack.LocalValues() { + visit(ctx, walk, localValue) + } for _, output := range stack.OutputValues() { visit(ctx, walk, output) } diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 1fcea52e8c..164a38006b 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6272,6 +6272,79 @@ func TestPlanWithResourceIdentities(t *testing.T) { } } +func TestPlanInvalidLocalValue(t *testing.T) { + ctx := context.Background() + cfg := loadMainBundleConfigForTest(t, "invalid-local") + + fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") + if err != nil { + t.Fatal(err) + } + + lock := depsfile.NewLocks() + lock.SetProvider( + addrs.NewDefaultProvider("testing"), + providerreqs.MustParseVersion("0.0.0"), + providerreqs.MustParseVersionConstraints("=0.0.0"), + providerreqs.PreferredHashes([]providerreqs.Hash{}), + ) + + changesCh := make(chan stackplan.PlannedChange, 8) + diagsCh := make(chan tfdiags.Diagnostic, 2) + req := PlanRequest{ + Config: cfg, + ForcePlanTimestamp: &fakePlanTimestamp, + ProviderFactories: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, + }, + DependencyLocks: *lock, + InputValues: map[stackaddrs.InputVariable]ExternalInputValue{ + {Name: "in"}: { + Value: cty.ObjectVal(map[string]cty.Value{"name": cty.StringVal("foo")}), + }, + }, + } + resp := PlanResponse{ + PlannedChanges: changesCh, + Diagnostics: diagsCh, + } + + go Plan(ctx, &req, &resp) + gotChanges, diags := collectPlanOutput(changesCh, diagsCh) + + tfdiags.AssertDiagnosticsMatch(t, diags, tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid operand", + Detail: "Unsuitable value for left operand: a number is required.", + Subject: &hcl.Range{ + Filename: "git::https://example.com/test.git//invalid-local/invalid-local.tfcomponent.hcl", + Start: hcl.Pos{Line: 19, Column: 49, Byte: 377}, + End: hcl.Pos{Line: 19, Column: 50, Byte: 378}, + }, + Context: &hcl.Range{ + Filename: "git::https://example.com/test.git//invalid-local/invalid-local.tfcomponent.hcl", + Start: hcl.Pos{Line: 19, Column: 49, Byte: 377}, + End: hcl.Pos{Line: 19, Column: 54, Byte: 382}, + }, + })) + + // We don't really care about the precise content of the plan changes here, + // we just want to ensure that the produced plan is not applyable + sort.SliceStable(gotChanges, func(i, j int) bool { + return plannedChangeSortKey(gotChanges[i]) < plannedChangeSortKey(gotChanges[j]) + }) + + pca, ok := gotChanges[0].(*stackplan.PlannedChangeApplyable) + if !ok { + t.Fatalf("expected first change to be PlannedChangeApplyable, got %T", gotChanges[0]) + } + if pca.Applyable { + t.Fatalf("expected plan to be not applyable due to invalid local value, but it is applyable") + } +} + // collectPlanOutput consumes the two output channels emitting results from // a call to [Plan], and collects all of the data written to them before // returning once changesCh has been closed by the sender to indicate that diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/invalid-local/invalid-local.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/invalid-local/invalid-local.tf new file mode 100644 index 0000000000..7275474393 --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/invalid-local/invalid-local.tf @@ -0,0 +1,7 @@ +variable "name" { + type = string +} + +resource "testing_resource" "hello" { + id = var.name +} diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/invalid-local/invalid-local.tfcomponent.hcl b/internal/stacks/stackruntime/testdata/mainbundle/test/invalid-local/invalid-local.tfcomponent.hcl new file mode 100644 index 0000000000..7f937433fa --- /dev/null +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/invalid-local/invalid-local.tfcomponent.hcl @@ -0,0 +1,31 @@ +required_providers { + testing = { + source = "hashicorp/testing" + version = "0.1.0" + } +} + +provider "testing" "main" {} + +variable "in" { + type = object({ + name = string + }) +} + +locals { + # This is not caught during the config evaluation but only when we try to + # evaluate this value during planning / applying. + invalid_local = { for k, v in var.in : k => v + 3 } +} + +component "self" { + source = "./" + inputs = { + name = "example#{local.invalid_local}" + } + + providers = { + testing = provider.testing.main + } +}