From d7461ce202f96a9d3a3cd2c13c011d51f145d0de Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 10 Nov 2023 15:33:40 -0800 Subject: [PATCH] stackeval: Initial tests for type "Provider" These tests focus on the behaviors related to deciding the set of dynamic instances for a particular provider and producing the values that act as references to providers. These tests also revealed a small but important bug in ExprReferenceValue in the for_each case, which is now fixed as part of this commit. --- .../internal/stackeval/provider.go | 12 +- .../internal/stackeval/provider_instance.go | 4 + .../internal/stackeval/provider_test.go | 418 ++++++++++++++++++ .../for_each/provider-for-each.tfstack.hcl | 12 + .../provider-single-instance.tfstack.hcl | 8 + .../sourcebundle/terraform-sources.json | 4 + 6 files changed, 455 insertions(+), 3 deletions(-) create mode 100644 internal/stacks/stackruntime/internal/stackeval/provider_test.go create mode 100644 internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/provider/for_each/provider-for-each.tfstack.hcl create mode 100644 internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/provider/single_instance/provider-single-instance.tfstack.hcl diff --git a/internal/stacks/stackruntime/internal/stackeval/provider.go b/internal/stacks/stackruntime/internal/stackeval/provider.go index a4fb399306..69d8c4c0e4 100644 --- a/internal/stacks/stackruntime/internal/stackeval/provider.go +++ b/internal/stacks/stackruntime/internal/stackeval/provider.go @@ -202,11 +202,17 @@ func (p *Provider) ExprReferenceValue(ctx context.Context, phase EvalPhase) cty. if !ok { panic(fmt.Sprintf("provider config with for_each has invalid instance key of type %T", instKey)) } - elems[string(k)] = cty.CapsuleVal(refType, &stackaddrs.ProviderConfigInstance{ - ProviderConfig: p.Addr().Item, - Key: instKey, + elems[string(k)] = cty.CapsuleVal(refType, &stackaddrs.AbsProviderConfigInstance{ + Stack: p.Addr().Stack, + Item: stackaddrs.ProviderConfigInstance{ + ProviderConfig: p.Addr().Item, + Key: instKey, + }, }) } + if len(elems) == 0 { + return cty.MapValEmpty(refType) + } return cty.MapVal(elems) default: if insts == nil { diff --git a/internal/stacks/stackruntime/internal/stackeval/provider_instance.go b/internal/stacks/stackruntime/internal/stackeval/provider_instance.go index 9847f7a6ad..514acbba00 100644 --- a/internal/stacks/stackruntime/internal/stackeval/provider_instance.go +++ b/internal/stacks/stackruntime/internal/stackeval/provider_instance.go @@ -58,6 +58,10 @@ func (p *ProviderInstance) Addr() stackaddrs.AbsProviderConfigInstance { } } +func (p *ProviderInstance) RepetitionData() instances.RepetitionData { + return p.repetition +} + func (p *ProviderInstance) ProviderType(ctx context.Context) *ProviderType { return p.main.ProviderType(ctx, p.Addr().Item.ProviderConfig.Provider) } diff --git a/internal/stacks/stackruntime/internal/stackeval/provider_test.go b/internal/stacks/stackruntime/internal/stackeval/provider_test.go new file mode 100644 index 0000000000..1afc75398b --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/provider_test.go @@ -0,0 +1,418 @@ +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/stacks/stackconfig/stackconfigtypes" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" +) + +func TestProviderCheckInstances(t *testing.T) { + getProvider := func(ctx context.Context, t *testing.T, main *Main) *Provider { + t.Helper() + mainStack := main.MainStack(ctx) + provider := mainStack.Provider(ctx, stackaddrs.ProviderConfig{ + Provider: addrs.MustParseProviderSourceString("terraform.io/builtin/foo"), + Name: "bar", + }) + if provider == nil { + t.Fatal("provider.foo.bar does not exist, but it should exist") + } + return provider + } + + subtestInPromisingTask(t, "single instance", func(ctx context.Context, t *testing.T) { + cfg := testStackConfig(t, "provider", "single_instance") + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + }) + + provider := getProvider(ctx, t, main) + forEachVal, diags := provider.CheckForEachValue(ctx, InspectPhase) + assertNoDiags(t, diags) + if forEachVal != cty.NilVal { + t.Fatalf("unexpected for_each value\ngot: %#v\nwant: cty.NilVal", forEachVal) + } + + insts, diags := provider.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, "provider", "for_each") + + subtestInPromisingTask(t, "no instances", func(ctx context.Context, t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "provider_instances": cty.MapValEmpty(cty.EmptyObject), + }, + }) + + provider := getProvider(ctx, t, main) + forEachVal, diags := provider.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 := provider.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.StringVal("in a"), + "b": cty.StringVal("in b"), + }) + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "provider_instances": wantForEachVal, + }, + }) + + provider := getProvider(ctx, t, main) + gotForEachVal, diags := provider.CheckForEachValue(ctx, InspectPhase) + assertNoDiags(t, diags) + if !wantForEachVal.RawEquals(gotForEachVal) { + t.Fatalf("unexpected for_each value\ngot: %#v\nwant: %#v", gotForEachVal, wantForEachVal) + } + insts, diags := provider.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.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.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{ + "provider_instances": cty.NullVal(cty.Map(cty.EmptyObject)), + }, + }) + + provider := getProvider(ctx, t, main) + gotVal, diags := provider.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{ + "provider_instances": cty.StringVal("nope"), + }, + }) + + provider := getProvider(ctx, t, main) + gotVal, diags := provider.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 := provider.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{ + "provider_instances": 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 provider must be deferred to a + // future plan after everything else has been applied first. + provider := getProvider(ctx, t, main) + gotVal, diags := provider.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 := provider.CheckInstances(ctx, InspectPhase) + assertNoDiags(t, diags) + if gotInsts != nil { + t.Errorf("wrong instances; want nil\n%#v", gotInsts) + } + }) + }) + +} + +func TestProviderExprReferenceValue(t *testing.T) { + providerTypeAddr := addrs.MustParseProviderSourceString("terraform.io/builtin/foo") + providerRefType := providerInstanceRefType(providerTypeAddr) + getProvider := func(ctx context.Context, t *testing.T, main *Main) *Provider { + t.Helper() + mainStack := main.MainStack(ctx) + provider := mainStack.Provider(ctx, stackaddrs.ProviderConfig{ + Provider: providerTypeAddr, + Name: "bar", + }) + if provider == nil { + t.Fatal("provider.foo.bar does not exist, but it should exist") + } + return provider + } + getRefFromVal := func(t *testing.T, v cty.Value) stackaddrs.AbsProviderConfigInstance { + t.Helper() + if !stackconfigtypes.IsProviderConfigType(v.Type()) { + t.Fatalf("result is not of a provider configuration reference type\ngot type: %#v", v.Type()) + } + return stackconfigtypes.ProviderInstanceForValue(v) + } + + subtestInPromisingTask(t, "single instance", func(ctx context.Context, t *testing.T) { + cfg := testStackConfig(t, "provider", "single_instance") + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{}, + }) + + provider := getProvider(ctx, t, main) + got := getRefFromVal(t, provider.ExprReferenceValue(ctx, InspectPhase)) + want := stackaddrs.AbsProviderConfigInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ProviderConfigInstance{ + ProviderConfig: stackaddrs.ProviderConfig{ + Provider: providerTypeAddr, + Name: "bar", + }, + Key: addrs.NoKey, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("wrong result\n%s", diff) + } + }) + t.Run("for_each", func(t *testing.T) { + cfg := testStackConfig(t, "provider", "for_each") + + subtestInPromisingTask(t, "no instances", func(ctx context.Context, t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "provider_instances": cty.MapValEmpty(cty.EmptyObject), + }, + }) + + provider := getProvider(ctx, t, main) + got := provider.ExprReferenceValue(ctx, InspectPhase) + want := cty.MapValEmpty(providerRefType) + if diff := cmp.Diff(want, got, ctydebug.CmpOptions); diff != "" { + t.Fatalf("wrong result\n%s", diff) + } + }) + subtestInPromisingTask(t, "two instances", func(ctx context.Context, t *testing.T) { + forEachVal := cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("in a"), + "b": cty.StringVal("in b"), + }) + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "provider_instances": forEachVal, + }, + }) + + provider := getProvider(ctx, t, main) + gotVal := provider.ExprReferenceValue(ctx, InspectPhase) + if !gotVal.Type().IsMapType() { + t.Fatalf("wrong result type\ngot type: %#v\nwant: map of provider references", gotVal.Type()) + } + if gotVal.IsNull() || !gotVal.IsKnown() { + t.Fatalf("wrong result\ngot: %#v\nwant: a known, non-null map of provider references", gotVal) + } + gotValMap := gotVal.AsValueMap() + if got, want := len(gotValMap), 2; got != want { + t.Errorf("wrong number of instances %d; want %d\n", got, want) + } + if gotVal := gotValMap["a"]; gotVal != cty.NilVal { + got := getRefFromVal(t, gotVal) + want := stackaddrs.AbsProviderConfigInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ProviderConfigInstance{ + ProviderConfig: stackaddrs.ProviderConfig{ + Provider: providerTypeAddr, + Name: "bar", + }, + Key: addrs.StringKey("a"), + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("wrong result for instance 'a'\n%s", diff) + } + } else { + t.Errorf("no element for instance 'a'") + } + if gotVal := gotValMap["b"]; gotVal != cty.NilVal { + got := getRefFromVal(t, gotVal) + want := stackaddrs.AbsProviderConfigInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ProviderConfigInstance{ + ProviderConfig: stackaddrs.ProviderConfig{ + Provider: providerTypeAddr, + Name: "bar", + }, + Key: addrs.StringKey("b"), + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("wrong result for instance 'b'\n%s", diff) + } + } else { + t.Errorf("no element for instance 'b'") + } + }) + subtestInPromisingTask(t, "null", func(ctx context.Context, t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "provider_instances": cty.NullVal(cty.Map(cty.EmptyObject)), + }, + }) + + provider := getProvider(ctx, t, main) + got := provider.ExprReferenceValue(ctx, InspectPhase) + // When the for_each expression is invalid, the result value + // is unknown so we can use it as a placeholder for partial + // downstream checking. + want := cty.UnknownVal(cty.Map(providerRefType)) + // FIXME: the cmp transformer ctydebug.CmpOptions seems to find + // this particular pair of values troubling, causing it to get + // into an infinite recursion. For now we'll just use RawEquals, + // at the expense of a less helpful failure message. This seems + // to be a bug in upstream ctydebug. + if !want.RawEquals(got) { + t.Fatalf("wrong result\ngot: %#v\nwant: %#v", got, want) + } + }) + subtestInPromisingTask(t, "string", func(ctx context.Context, t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "provider_instances": cty.StringVal("nope"), + }, + }) + + provider := getProvider(ctx, t, main) + got := provider.ExprReferenceValue(ctx, InspectPhase) + // When the for_each expression is invalid, the result value + // is unknown so we can use it as a placeholder for partial + // downstream checking. + want := cty.UnknownVal(cty.Map(providerRefType)) + // FIXME: the cmp transformer ctydebug.CmpOptions seems to find + // this particular pair of values troubling, causing it to get + // into an infinite recursion. For now we'll just use RawEquals, + // at the expense of a less helpful failure message. This seems + // to be a bug in upstream ctydebug. + if !want.RawEquals(got) { + t.Fatalf("wrong result\ngot: %#v\nwant: %#v", got, want) + } + }) + subtestInPromisingTask(t, "unknown", func(ctx context.Context, t *testing.T) { + main := testEvaluator(t, testEvaluatorOpts{ + Config: cfg, + TestOnlyGlobals: map[string]cty.Value{ + "provider_instances": cty.UnknownVal(cty.Map(cty.EmptyObject)), + }, + }) + + provider := getProvider(ctx, t, main) + got := provider.ExprReferenceValue(ctx, InspectPhase) + // When the for_each expression is unknown, the result value + // is unknown too so we can use it as a placeholder for partial + // downstream checking. + want := cty.UnknownVal(cty.Map(providerRefType)) + // FIXME: the cmp transformer ctydebug.CmpOptions seems to find + // this particular pair of values troubling, causing it to get + // into an infinite recursion. For now we'll just use RawEquals, + // at the expense of a less helpful failure message. This seems + // to be a bug in upstream ctydebug. + if !want.RawEquals(got) { + t.Fatalf("wrong result\ngot: %#v\nwant: %#v", got, want) + } + }) + }) +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/provider/for_each/provider-for-each.tfstack.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/provider/for_each/provider-for-each.tfstack.hcl new file mode 100644 index 0000000000..7a13f28ed0 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/provider/for_each/provider-for-each.tfstack.hcl @@ -0,0 +1,12 @@ +# Set the test-only global "provider_instances" to the value that should be +# assigned to the provider blocks' for_each argument. + +required_providers { + foo = { + source = "terraform.io/builtin/foo" + } +} + +provider "foo" "bar" { + for_each = _test_only_global.provider_instances +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/provider/single_instance/provider-single-instance.tfstack.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/provider/single_instance/provider-single-instance.tfstack.hcl new file mode 100644 index 0000000000..b7f2855efa --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/provider/single_instance/provider-single-instance.tfstack.hcl @@ -0,0 +1,8 @@ +required_providers { + foo = { + source = "terraform.io/builtin/foo" + } +} + +provider "foo" "bar" { +} 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 09d7d1c3b5..31f420503b 100644 --- a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/terraform-sources.json +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/terraform-sources.json @@ -16,6 +16,10 @@ { "source": "https://testing.invalid/component.tar.gz", "local": "component" + }, + { + "source": "https://testing.invalid/provider.tar.gz", + "local": "provider" } ] }