stackeval: Fix input variable evaluation in embedded stacks

Two bugs were making this not work quite right:
 - The Stack type wasn't treating its child stack objects as singletons,
   instead creating a new one on each request and therefore preventing
   child objects from being treated as singletons too.
 - StackCallInstance.CheckInputVariableValues wasn't actually using the
   value from its expression evaluation, and was instead just always
   returning an empty object.

This commit also includes the tests that helped find these bugs.
pull/34738/head
Martin Atkins 3 years ago
parent 9a943af56d
commit 992f6d4115

@ -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
})
})
}
})
}
}

@ -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,

@ -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)

@ -0,0 +1,12 @@
variable "name" {
type = string
}
stack "child" {
source = "./child"
inputs = {
name = "child of ${var.name}"
}
}

@ -0,0 +1,9 @@
{
"terraform_source_bundle": 1,
"packages": [
{
"source": "https://testing.invalid/input_variable.tar.gz",
"local": "input_variable"
}
]
}

@ -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: "<test-input>",
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
}
Loading…
Cancel
Save