diff --git a/internal/stacks/stackruntime/internal/stackeval/stack_call_instance.go b/internal/stacks/stackruntime/internal/stackeval/stack_call_instance.go index 01024712d7..4f1a73b62d 100644 --- a/internal/stacks/stackruntime/internal/stackeval/stack_call_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/stack_call_instance.go @@ -46,6 +46,10 @@ func newStackCallInstance(call *StackCall, key addrs.InstanceKey, repetition ins } } +func (c *StackCallInstance) RepetitionData() instances.RepetitionData { + return c.repetition +} + // CallerStack returns the stack instance that contains the call that this // is an instance of. func (c *StackCallInstance) CallerStack(ctx context.Context) *Stack { diff --git a/internal/stacks/stackruntime/internal/stackeval/stack_call_test.go b/internal/stacks/stackruntime/internal/stackeval/stack_call_test.go new file mode 100644 index 0000000000..a6b18f54f4 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/stack_call_test.go @@ -0,0 +1,226 @@ +package stackeval + +import ( + "context" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/instances" + "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" +) + +func TestStackCallCheckInstances(t *testing.T) { + getStackCall := func(ctx context.Context, main *Main) *StackCall { + mainStack := main.MainStack(ctx) + call := mainStack.EmbeddedStackCall(ctx, stackaddrs.StackCall{Name: "child"}) + if call == nil { + t.Fatal("stack.child does not exist, but it should exist") + } + return call + } + + subtestInPromisingTask(t, "single instance", func(ctx context.Context, t *testing.T) { + cfg := testStackConfig(t, "stack_call", "single_instance") + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "child_stack_inputs": cty.EmptyObjectVal, + }, + }) + + call := getStackCall(ctx, main) + forEachVal, diags := call.CheckForEachValue(ctx, InspectPhase) + assertNoDiags(t, diags) + if forEachVal != cty.NilVal { + t.Fatalf("unexpected for_each value\ngot: %#v\nwant: cty.NilVal", forEachVal) + } + + insts, diags := call.CheckInstances(ctx, InspectPhase) + if got, want := len(insts), 1; got != want { + t.Fatalf("wrong number of instances %d; want %d\n%#v", got, want, insts) + } + inst, ok := insts[addrs.NoKey] + if !ok { + t.Fatalf("missing expected addrs.NoKey instance\n%s", spew.Sdump(insts)) + } + if diff := cmp.Diff(instances.RepetitionData{}, inst.RepetitionData(), ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong repetition data\n%s", diff) + } + }) + t.Run("for_each", func(t *testing.T) { + cfg := testStackConfig(t, "stack_call", "for_each") + + subtestInPromisingTask(t, "no instances", func(ctx context.Context, t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "child_stack_for_each": cty.MapValEmpty(cty.EmptyObject), + }, + }) + + call := getStackCall(ctx, main) + forEachVal, diags := call.CheckForEachValue(ctx, InspectPhase) + assertNoDiags(t, diags) + if got, want := forEachVal, cty.MapValEmpty(cty.EmptyObject); !want.RawEquals(got) { + t.Fatalf("unexpected for_each value\ngot: %#v\nwant: %#v", got, want) + } + insts, diags := call.CheckInstances(ctx, InspectPhase) + if got, want := len(insts), 0; got != want { + t.Fatalf("wrong number of instances %d; want %d\n%#v", got, want, insts) + } + + // For this particular function we take the unusual approach of + // distinguishing between a nil map and a non-nil empty map so + // we can distinguish between "definitely no instances" (this case) + // and "we don't know how many instances there are" (tested in other + // subtests of this test, below.) + if insts == nil { + t.Error("CheckInstances result is nil; should be non-nil empty map") + } + }) + subtestInPromisingTask(t, "two instances", func(ctx context.Context, t *testing.T) { + wantForEachVal := cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "test_string": cty.StringVal("in a"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "test_string": cty.StringVal("in b"), + }), + }) + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "child_stack_for_each": wantForEachVal, + }, + }) + + call := getStackCall(ctx, main) + gotForEachVal, diags := call.CheckForEachValue(ctx, InspectPhase) + assertNoDiags(t, diags) + if !wantForEachVal.RawEquals(gotForEachVal) { + t.Fatalf("unexpected for_each value\ngot: %#v\nwant: %#v", gotForEachVal, wantForEachVal) + } + insts, diags := call.CheckInstances(ctx, InspectPhase) + if got, want := len(insts), 2; got != want { + t.Fatalf("wrong number of instances %d; want %d\n%#v", got, want, insts) + } + t.Run("instance a", func(t *testing.T) { + inst, ok := insts[addrs.StringKey("a")] + if !ok { + t.Fatalf("missing expected addrs.StringKey(\"a\") instance\n%s", spew.Sdump(insts)) + } + wantRepData := instances.RepetitionData{ + EachKey: cty.StringVal("a"), + EachValue: cty.ObjectVal(map[string]cty.Value{ + "test_string": cty.StringVal("in a"), + }), + } + if diff := cmp.Diff(wantRepData, inst.RepetitionData(), ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong repetition data\n%s", diff) + } + }) + t.Run("instance b", func(t *testing.T) { + inst, ok := insts[addrs.StringKey("b")] + if !ok { + t.Fatalf("missing expected addrs.StringKey(\"b\") instance\n%s", spew.Sdump(insts)) + } + wantRepData := instances.RepetitionData{ + EachKey: cty.StringVal("b"), + EachValue: cty.ObjectVal(map[string]cty.Value{ + "test_string": cty.StringVal("in b"), + }), + } + if diff := cmp.Diff(wantRepData, inst.RepetitionData(), ctydebug.CmpOptions); diff != "" { + t.Errorf("wrong repetition data\n%s", diff) + } + }) + }) + subtestInPromisingTask(t, "null", func(ctx context.Context, t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "child_stack_for_each": cty.NullVal(cty.Map(cty.EmptyObject)), + }, + }) + + call := getStackCall(ctx, main) + gotVal, diags := call.CheckForEachValue(ctx, InspectPhase) + assertMatchingDiag(t, diags, func(diag tfdiags.Diagnostic) bool { + return diag.Severity() == tfdiags.Error && diag.Description().Detail == "The for_each value must not be null." + }) + wantVal := cty.DynamicVal // placeholder for invalid result + if !wantVal.RawEquals(gotVal) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", gotVal, wantVal) + } + }) + subtestInPromisingTask(t, "string", func(ctx context.Context, t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "child_stack_for_each": cty.StringVal("nope"), + }, + }) + + call := getStackCall(ctx, main) + gotVal, diags := call.CheckForEachValue(ctx, InspectPhase) + assertMatchingDiag(t, diags, func(diag tfdiags.Diagnostic) bool { + return (diag.Severity() == tfdiags.Error && + diag.Description().Detail == "The for_each expression must produce either a map of any type or a set of strings. The keys of the map or the set elements will serve as unique identifiers for multiple instances of this embedded stack.") + }) + wantVal := cty.DynamicVal // placeholder for invalid result + if !wantVal.RawEquals(gotVal) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", gotVal, wantVal) + } + + // When the for_each expression is invalid, CheckInstances should + // return nil to represent that we don't know enough to predict + // how many instances there are. This is a different result than + // when we know there are zero instances, which would be a non-nil + // empty map. + gotInsts, diags := call.CheckInstances(ctx, InspectPhase) + assertNoDiags(t, diags) + if gotInsts != nil { + t.Errorf("wrong instances; want nil\n%#v", gotInsts) + } + }) + subtestInPromisingTask(t, "unknown", func(ctx context.Context, t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "child_stack_for_each": cty.UnknownVal(cty.Map(cty.EmptyObject)), + }, + }) + + // For now it's invalid to use an unknown value in for_each. + // Later we're expecting to make this succeed but announce that + // planning everything beneath this call must be deferred to a + // future plan after everything else has been applied first. + call := getStackCall(ctx, main) + gotVal, diags := call.CheckForEachValue(ctx, InspectPhase) + assertMatchingDiag(t, diags, func(diag tfdiags.Diagnostic) bool { + return (diag.Severity() == tfdiags.Error && + diag.Description().Detail == "The for_each value must not be derived from values that will be determined only during the apply phase.") + }) + wantVal := cty.UnknownVal(cty.Map(cty.EmptyObject)) + if !wantVal.RawEquals(gotVal) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", gotVal, wantVal) + } + + // When the for_each expression is invalid, CheckInstances should + // return nil to represent that we don't know enough to predict + // how many instances there are. This is a different result than + // when we know there are zero instances, which would be a non-nil + // empty map. + gotInsts, diags := call.CheckInstances(ctx, InspectPhase) + assertNoDiags(t, diags) + if gotInsts != nil { + t.Errorf("wrong instances; want nil\n%#v", gotInsts) + } + }) + }) +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/empty/empty.tfstack.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/empty/empty.tfstack.hcl new file mode 100644 index 0000000000..d2235eb2e5 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/empty/empty.tfstack.hcl @@ -0,0 +1,3 @@ +# This stack configuration is intentionally left empty; we use it as the +# configuration for a child stack in some of our StackCall and StackCallInstance +# tests where the content of the child stack is irrelevant to the test. diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/for_each/stack-call-for-each.tfstack.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/for_each/stack-call-for-each.tfstack.hcl new file mode 100644 index 0000000000..29044c05f2 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/for_each/stack-call-for-each.tfstack.hcl @@ -0,0 +1,14 @@ +# Set the test-only global "child_stack_for_each" to a map conforming +# to the following type constraint: +# +# map(object({ +# test_string = optional(string) +# test_map = optional(map(string)) +# })) + +stack "child" { + source = "../with_variables_and_outputs" + for_each = _test_only_global.child_stack_for_each + + inputs = each.value +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/single_instance/stack-call-single-instance.tfstack.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/single_instance/stack-call-single-instance.tfstack.hcl new file mode 100644 index 0000000000..7ee7070512 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/single_instance/stack-call-single-instance.tfstack.hcl @@ -0,0 +1,13 @@ +# Set the test-only global "child_stack_inputs" to an object conforming +# to the following type constraint: +# +# object({ +# test_string = optional(string) +# test_map = optional(map(string)) +# }) + +stack "child" { + source = "../with_variables_and_outputs" + + inputs = _test_only_global.child_stack_inputs +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/with_variables_and_outputs/with-variables_and_outputs.tfstack.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/with_variables_and_outputs/with-variables_and_outputs.tfstack.hcl new file mode 100644 index 0000000000..3adeed4e7c --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/stack_call/with_variables_and_outputs/with-variables_and_outputs.tfstack.hcl @@ -0,0 +1,29 @@ +# This is intended for use as a child stack configuration for situations +# where we're testing the propagation of input variables into the child +# stack. +# +# It contains some input variable declarations that we can assign values to +# for testing purposes. Both are optional to give flexibility for reusing +# this across multiple test cases. If you need something more sophisticated +# for your test, prefer to write a new configuration rather than growing this +# one any further. + +variable "test_string" { + type = string + default = null +} + +variable "test_map" { + type = map(string) + default = null +} + +output "test_string" { + type = string + value = var.test_string +} + +output "test_map" { + type = string + value = var.test_map +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/terraform-sources.json b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/terraform-sources.json index 050cfd2f81..ca2f2ce52c 100644 --- a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/terraform-sources.json +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/terraform-sources.json @@ -8,6 +8,10 @@ { "source": "https://testing.invalid/output_value.tar.gz", "local": "output_value" + }, + { + "source": "https://testing.invalid/stack_call.tar.gz", + "local": "stack_call" } ] } diff --git a/internal/stacks/stackruntime/internal/stackeval/testing_test.go b/internal/stacks/stackruntime/internal/stackeval/testing_test.go index 4c7d982a94..3ba9d3c3b1 100644 --- a/internal/stacks/stackruntime/internal/stackeval/testing_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/testing_test.go @@ -1,11 +1,13 @@ package stackeval import ( + "context" "fmt" "testing" "github.com/hashicorp/go-slug/sourceaddrs" "github.com/hashicorp/go-slug/sourcebundle" + "github.com/hashicorp/terraform/internal/promising" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" "github.com/hashicorp/terraform/internal/stacks/stackconfig" "github.com/hashicorp/terraform/internal/stacks/stackstate" @@ -151,6 +153,56 @@ type testEvaluatorOpts struct { // methods that are intended only as internal API, since it's a public API // from the perspective of a test caller even though it's not public to // other callers. -func (m *Main) SetTestOnlyGlobals(vals map[string]cty.Value) { +func (m *Main) SetTestOnlyGlobals(t *testing.T, vals map[string]cty.Value) { m.testOnlyGlobals = vals } + +func assertNoDiags(t *testing.T, diags tfdiags.Diagnostics) { + t.Helper() + if len(diags) != 0 { + t.Fatalf("unexpected diagnostics\n%s", diags.Err()) + } +} + +func assertMatchingDiag(t *testing.T, diags tfdiags.Diagnostics, check func(diag tfdiags.Diagnostic) bool) { + t.Helper() + for _, diag := range diags { + if check(diag) { + return + } + } + t.Fatalf("none of the diagnostics is the one we are expecting\n%s", diags.Err()) +} + +// inPromisingTask is a helper for conveniently running some code in the context +// of a [promising.MainTask], with automatic promise error checking. This +// makes it valid to call functions that expect to run only as part of a +// promising task, which is true of essentially every method in this package +// that takes a [context.Context] as its first argument. +// +// Specifically, if the function encounters any direct promise-related failures, +// such as failure to resolve a promise before returning, this function will +// halt the test with an error message. +func inPromisingTask(t *testing.T, f func(ctx context.Context, t *testing.T)) { + t.Helper() + _, err := promising.MainTask(context.Background(), func(ctx context.Context) (struct{}, error) { + t.Helper() + f(ctx, t) + return struct{}{}, nil + }) + if err != nil { + // We could get here if the test produces any self-references or + // if it creates any promises that are left unresolved once it exits. + t.Fatalf("promise resolution failure: %s", err) + } +} + +// subtestInPromisingTask compiles [testing.T.Run] with [inPromisingTask] as +// a convenience wrapper for running an entire subtest as a [promising.MainTask]. +func subtestInPromisingTask(t *testing.T, name string, f func(ctx context.Context, t *testing.T)) { + t.Helper() + t.Run(name, func(t *testing.T) { + t.Helper() + inPromisingTask(t, f) + }) +}