diff --git a/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go new file mode 100644 index 0000000000..809664d6ff --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/input_variable_test.go @@ -0,0 +1,113 @@ +package stackeval + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/promising" + "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/zclconf/go-cty/cty" +) + +func TestInputVariableValue(t *testing.T) { + ctx := context.Background() + cfg := testStackConfig(t, "input_variable", "basics") + + childStackAddr := stackaddrs.RootStackInstance.Child("child", addrs.NoKey) + + tests := map[string]struct { + NameVal cty.Value + WantRootVal cty.Value + WantChildVal cty.Value + + WantRootErr bool + }{ + "known string": { + NameVal: cty.StringVal("jackson"), + WantRootVal: cty.StringVal("jackson"), + WantChildVal: cty.StringVal("child of jackson"), + }, + "unknown string": { + NameVal: cty.UnknownVal(cty.String), + WantRootVal: cty.UnknownVal(cty.String), + WantChildVal: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefix("child of "). + NewValue(), + }, + "unknown of unknown type": { + NameVal: cty.DynamicVal, + WantRootVal: cty.UnknownVal(cty.String), + WantChildVal: cty.UnknownVal(cty.String).Refine(). + NotNull(). + StringPrefix("child of "). + NewValue(), + }, + "bool": { + // This one is testing that the given value gets converted to + // the declared type constraint, which is string in this case. + NameVal: cty.True, + WantRootVal: cty.StringVal("true"), + WantChildVal: cty.StringVal("child of true"), + }, + "object": { + // This one is testing that the given value gets converted to + // the declared type constraint, which is string in this case. + NameVal: cty.EmptyObjectVal, + WantRootErr: true, // Type mismatch error + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + InputVariableValues: map[string]cty.Value{ + "name": test.NameVal, + }, + }) + + t.Run("root", func(t *testing.T) { + promising.MainTask(ctx, func(ctx context.Context) (struct{}, error) { + mainStack := main.MainStack(ctx) + rootVar := mainStack.InputVariable(ctx, stackaddrs.InputVariable{Name: "name"}) + got, diags := rootVar.CheckValue(ctx, InspectPhase) + + if test.WantRootErr { + if !diags.HasErrors() { + t.Errorf("succeeded; want error\ngot: %#v", got) + } + return struct{}{}, nil + } + + if diags.HasErrors() { + t.Errorf("unexpected errors\n%s", diags.Err().Error()) + } + want := test.WantRootVal + if !want.RawEquals(got) { + t.Errorf("wrong value\ngot: %#v\nwant: %#v", got, want) + } + return struct{}{}, nil + }) + }) + if !test.WantRootErr { + t.Run("child", func(t *testing.T) { + promising.MainTask(ctx, func(ctx context.Context) (struct{}, error) { + childStack := main.Stack(ctx, childStackAddr, InspectPhase) + rootVar := childStack.InputVariable(ctx, stackaddrs.InputVariable{Name: "name"}) + got, diags := rootVar.CheckValue(ctx, InspectPhase) + if diags.HasErrors() { + t.Errorf("unexpected errors\n%s", diags.Err().Error()) + } + want := test.WantChildVal + if !want.RawEquals(got) { + t.Errorf("wrong value\ngot: %#v\nwant: %#v", got, want) + } + return struct{}{}, nil + }) + }) + } + }) + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/stack.go b/internal/stacks/stackruntime/internal/stackeval/stack.go index 71d932c75d..46d5eca8fd 100644 --- a/internal/stacks/stackruntime/internal/stackeval/stack.go +++ b/internal/stacks/stackruntime/internal/stackeval/stack.go @@ -31,7 +31,12 @@ type Stack struct { // The remaining fields memoize other objects we might create in response // to method calls. Must lock "mu" before interacting with them. - mu sync.Mutex + mu sync.Mutex + // childStacks is a cache of all of the child stacks that have been + // requested, but this could include non-existant child stacks requested + // through ChildStackUnchecked, so should not be used directly by + // anything that needs to return only child stacks that actually exist; + // use ChildStackChecked if you need to be sure it's actually configured. childStacks map[stackaddrs.StackInstanceStep]*Stack inputVariables map[stackaddrs.InputVariable]*InputVariable stackCalls map[stackaddrs.StackCall]*StackCall @@ -80,15 +85,23 @@ func (s *Stack) ParentStack(ctx context.Context) *Stack { // then consider [Stack.ChildStackChecked], but note that it's more expensive // because it must block for the "for_each" expression to be fully resolved. func (s *Stack) ChildStackUnchecked(ctx context.Context, addr stackaddrs.StackInstanceStep) *Stack { - // First we'll check if the step address refers to a calls := s.EmbeddedStackCalls(ctx) callAddr := stackaddrs.StackCall{Name: addr.Name} if _, exists := calls[callAddr]; !exists { return nil } - childAddr := s.Addr().Child(addr.Name, addr.Key) - return newStack(s.main, childAddr) + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.childStacks[addr]; !exists { + childAddr := s.Addr().Child(addr.Name, addr.Key) + if s.childStacks == nil { + s.childStacks = make(map[stackaddrs.StackInstanceStep]*Stack) + } + s.childStacks[addr] = newStack(s.main, childAddr) + } + + return s.childStacks[addr] } // ChildStackChecked returns an object representing a child of this stack, diff --git a/internal/stacks/stackruntime/internal/stackeval/stack_call_instance.go b/internal/stacks/stackruntime/internal/stackeval/stack_call_instance.go index eb01dc8ed0..01024712d7 100644 --- a/internal/stacks/stackruntime/internal/stackeval/stack_call_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/stack_call_instance.go @@ -121,6 +121,7 @@ func (c *StackCallInstance) CheckInputVariableValues(ctx context.Context, phase } expr = result.Expression hclCtx = result.EvalContext + v = result.Value } v = defs.Apply(v) diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/basics/child/input-variable-basics-child.tfstack.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/basics/child/input-variable-basics-child.tfstack.hcl new file mode 100644 index 0000000000..3f2eec322d --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/basics/child/input-variable-basics-child.tfstack.hcl @@ -0,0 +1,4 @@ + +variable "name" { + type = string +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/basics/input-variable-basics.tfstack.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/basics/input-variable-basics.tfstack.hcl new file mode 100644 index 0000000000..d0544ee830 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/input_variable/basics/input-variable-basics.tfstack.hcl @@ -0,0 +1,12 @@ + +variable "name" { + type = string +} + +stack "child" { + source = "./child" + + inputs = { + name = "child of ${var.name}" + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/terraform-sources.json b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/terraform-sources.json new file mode 100644 index 0000000000..c197cfa94f --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/terraform-sources.json @@ -0,0 +1,9 @@ +{ + "terraform_source_bundle": 1, + "packages": [ + { + "source": "https://testing.invalid/input_variable.tar.gz", + "local": "input_variable" + } + ] + } diff --git a/internal/stacks/stackruntime/internal/stackeval/testing_test.go b/internal/stacks/stackruntime/internal/stackeval/testing_test.go new file mode 100644 index 0000000000..bfd492f056 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testing_test.go @@ -0,0 +1,109 @@ +package stackeval + +import ( + "fmt" + "testing" + + "github.com/hashicorp/go-slug/sourceaddrs" + "github.com/hashicorp/go-slug/sourcebundle" + "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/stacks/stackconfig" + "github.com/hashicorp/terraform/internal/stacks/stackstate" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +// This file contains some general test utilities that many of our other +// _test.go files rely on. It doesn't actually contain any tests itself. + +// testStackConfig loads a stack configuration from the source bundle in this +// package's testdata directory. +// +// "collection" is the name of one of the synthetic source packages that's +// declared in the source bundle, and "subPath" is the path within that +// package. +func testStackConfig(t *testing.T, collection string, subPath string) *stackconfig.Config { + t.Helper() + + // Our collection of test configurations is laid out like a source + // bundle that was installed from some source addresses that don't + // really exist, and so we'll construct a suitable fake source + // address following that scheme. + fakeSrcStr := fmt.Sprintf("https://testing.invalid/%s.tar.gz//%s", collection, subPath) + fakeSrc, err := sourceaddrs.ParseRemoteSource(fakeSrcStr) + if err != nil { + t.Fatalf("artificial source address string %q is invalid: %s", fakeSrcStr, err) + } + + sources, err := sourcebundle.OpenDir("testdata/sourcebundle") + if err != nil { + t.Fatalf("cannot open source bundle: %s", err) + } + + ret, diags := stackconfig.LoadConfigDir(fakeSrc, sources) + if diags.HasErrors() { + t.Fatalf("configuration is invalid\n%s", diags.Err().Error()) + } + return ret +} + +// testEvaluator constructs a [Main] that's configured for [InspectPhase] using +// the given configuration, state, and other options. +// +// This evaluator is suitable for tests that focus only on evaluation logic +// within this package, but will not be suitable for all situations. Some +// tests should instantiate [Main] directly, particularly if they intend to +// exercise phase-specific functionality like planning or applying component +// instances. +func testEvaluator(t *testing.T, opts testEvaluatorOpts) *Main { + t.Helper() + if opts.Config == nil { + t.Fatal("Config field must not be nil") + } + if opts.State == nil { + opts.State = stackstate.NewState() + } + + inputVals := make(map[stackaddrs.InputVariable]ExternalInputValue, len(opts.InputVariableValues)) + for name, val := range opts.InputVariableValues { + inputVals[stackaddrs.InputVariable{Name: name}] = ExternalInputValue{ + Value: val, + DefRange: tfdiags.SourceRange{ + Filename: "", + Start: tfdiags.SourcePos{ + Line: 1, + Column: 1, + Byte: 0, + }, + End: tfdiags.SourcePos{ + Line: 1, + Column: 1, + Byte: 0, + }, + }, + } + } + + return NewForInspecting(opts.Config, opts.State, InspectOpts{ + InputVariableValues: inputVals, + ProviderFactories: opts.ProviderFactories, + }) +} + +type testEvaluatorOpts struct { + // Config is required. + Config *stackconfig.Config + + // State is optional; testEvaluator will use an empty state if this is nil. + State *stackstate.State + + // InputVariableValues is optional and if set will provide the values + // for the root stack input variables. Any variable not defined here + // will evaluate to an unknown value of the configured type. + InputVariableValues map[string]cty.Value + + // ProviderFactories is optional and if set provides factory functions + // for provider types that the test can use. If not set then any attempt + // to use provider configurations will lead to some sort of error. + ProviderFactories ProviderFactories +}