diff --git a/internal/stacks/stackruntime/apply_destroy_test.go b/internal/stacks/stackruntime/apply_destroy_test.go index d482e80a18..64d6514f50 100644 --- a/internal/stacks/stackruntime/apply_destroy_test.go +++ b/internal/stacks/stackruntime/apply_destroy_test.go @@ -6,11 +6,11 @@ package stackruntime import ( "context" "path" - "sort" + "strconv" "testing" "time" - "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" @@ -35,21 +35,12 @@ func TestApplyDestroy(t *testing.T) { t.Fatal(err) } - type cycle struct { - mode plans.Mode - inputs map[string]cty.Value - wantPlannedChanges []stackplan.PlannedChange - wantPlannedDiags []expectedDiagnostic - wantAppliedChanges []stackstate.AppliedChange - wantAppliedDiags []expectedDiagnostic - } - tcs := map[string]struct { path string description string state *stackstate.State store *stacks_testing_provider.ResourceStore - cycles []cycle + cycles []TestCycle }{ "missing-resource": { path: path.Join("with-single-input", "valid"), @@ -70,10 +61,10 @@ func TestApplyDestroy(t *testing.T) { Status: states.ObjectReady, })). Build(), - cycles: []cycle{ + cycles: []TestCycle{ { - mode: plans.DestroyMode, - inputs: map[string]cty.Value{ + planMode: plans.DestroyMode, + planInputs: map[string]cty.Value{ "input": cty.StringVal("hello"), }, wantAppliedChanges: []stackstate.AppliedChange{ @@ -119,10 +110,10 @@ func TestApplyDestroy(t *testing.T) { Status: states.ObjectReady, })). Build(), - cycles: []cycle{ + cycles: []TestCycle{ { - mode: plans.DestroyMode, - inputs: map[string]cty.Value{ + planMode: plans.DestroyMode, + planInputs: map[string]cty.Value{ "id": cty.StringVal("foo"), "resource": cty.StringVal("bar"), }, @@ -183,10 +174,10 @@ func TestApplyDestroy(t *testing.T) { Status: states.ObjectReady, })). Build(), - cycles: []cycle{ + cycles: []TestCycle{ { - mode: plans.NormalMode, - inputs: map[string]cty.Value{ + planMode: plans.NormalMode, + planInputs: map[string]cty.Value{ "id": cty.StringVal("foo"), "resource": cty.StringVal("bar"), }, @@ -239,8 +230,8 @@ func TestApplyDestroy(t *testing.T) { }, }, { - mode: plans.DestroyMode, - inputs: map[string]cty.Value{ + planMode: plans.DestroyMode, + planInputs: map[string]cty.Value{ "id": cty.StringVal("foo"), "resource": cty.StringVal("bar"), }, @@ -273,9 +264,9 @@ func TestApplyDestroy(t *testing.T) { "dependent-resources": { path: "dependent-component", description: "test the order of operations during create and destroy", - cycles: []cycle{ + cycles: []TestCycle{ { - mode: plans.NormalMode, + planMode: plans.NormalMode, wantAppliedChanges: []stackstate.AppliedChange{ &stackstate.AppliedChangeComponentInstance{ ComponentAddr: mustAbsComponent("component.self"), @@ -329,7 +320,7 @@ func TestApplyDestroy(t *testing.T) { }, }, { - mode: plans.DestroyMode, + planMode: plans.DestroyMode, wantAppliedChanges: []stackstate.AppliedChange{ &stackstate.AppliedChangeComponentInstance{ ComponentAddr: mustAbsComponent("component.self"), @@ -391,9 +382,9 @@ func TestApplyDestroy(t *testing.T) { "fail_apply": cty.True, })). Build(), - cycles: []cycle{ + cycles: []TestCycle{ { - mode: plans.DestroyMode, + planMode: plans.DestroyMode, wantAppliedChanges: []stackstate.AppliedChange{ &stackstate.AppliedChangeComponentInstance{ ComponentAddr: mustAbsComponent("component.self"), @@ -422,18 +413,22 @@ func TestApplyDestroy(t *testing.T) { Schema: stacks_testing_provider.FailedResourceSchema, }, }, - wantAppliedDiags: []expectedDiagnostic{ - expectDiagnostic(tfdiags.Error, "failedResource error", "failed during apply"), - }, + wantAppliedDiags: initDiags(func(diags tfdiags.Diagnostics) tfdiags.Diagnostics { + return diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "failedResource error", + Detail: "failed during apply", + }) + }), }, }, }, "destroy-after-failed-apply": { path: path.Join("with-single-input", "failed-child"), description: "tests destroying when state is only partially applied", - cycles: []cycle{ + cycles: []TestCycle{ { - mode: plans.NormalMode, + planMode: plans.NormalMode, wantAppliedChanges: []stackstate.AppliedChange{ &stackstate.AppliedChangeComponentInstance{ ComponentAddr: mustAbsComponent("component.child"), @@ -475,12 +470,16 @@ func TestApplyDestroy(t *testing.T) { Schema: stacks_testing_provider.TestingResourceSchema, }, }, - wantAppliedDiags: []expectedDiagnostic{ - expectDiagnostic(tfdiags.Error, "failedResource error", "failed during apply"), - }, + wantAppliedDiags: initDiags(func(diags tfdiags.Diagnostics) tfdiags.Diagnostics { + return diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "failedResource error", + Detail: "failed during apply", + }) + }), }, { - mode: plans.DestroyMode, + planMode: plans.DestroyMode, wantAppliedChanges: []stackstate.AppliedChange{ &stackstate.AppliedChangeComponentInstance{ ComponentAddr: mustAbsComponent("component.child"), @@ -515,9 +514,9 @@ func TestApplyDestroy(t *testing.T) { "destroy-after-deferred-apply": { path: "deferred-dependent", description: "tests what happens when a destroy plan is applied after components have been deferred", - cycles: []cycle{ + cycles: []TestCycle{ { - mode: plans.NormalMode, + planMode: plans.NormalMode, wantAppliedChanges: []stackstate.AppliedChange{ &stackstate.AppliedChangeComponentInstance{ ComponentAddr: mustAbsComponent("component.deferred"), @@ -555,7 +554,7 @@ func TestApplyDestroy(t *testing.T) { }, }, { - mode: plans.DestroyMode, + planMode: plans.DestroyMode, wantAppliedChanges: []stackstate.AppliedChange{ &stackstate.AppliedChangeComponentInstance{ ComponentAddr: mustAbsComponent("component.deferred"), @@ -626,9 +625,9 @@ func TestApplyDestroy(t *testing.T) { "deferred": cty.True, })). Build(), - cycles: []cycle{ + cycles: []TestCycle{ { - mode: plans.DestroyMode, + planMode: plans.DestroyMode, wantPlannedChanges: []stackplan.PlannedChange{ &stackplan.PlannedChangeApplyable{ Applyable: true, @@ -762,13 +761,13 @@ func TestApplyDestroy(t *testing.T) { "destroy-with-input-dependency": { path: path.Join("with-single-input-and-output", "input-dependency"), description: "tests destroy operations with input dependencies", - cycles: []cycle{ + cycles: []TestCycle{ { // Just create everything normally, and don't validate it. - mode: plans.NormalMode, + planMode: plans.NormalMode, }, { - mode: plans.DestroyMode, + planMode: plans.DestroyMode, wantAppliedChanges: []stackstate.AppliedChange{ &stackstate.AppliedChangeComponentInstance{ ComponentAddr: mustAbsComponent("component.child"), @@ -805,13 +804,13 @@ func TestApplyDestroy(t *testing.T) { "destroy-with-provider-dependency": { path: path.Join("with-single-input-and-output", "provider-dependency"), description: "tests destroy operations with provider dependencies", - cycles: []cycle{ + cycles: []TestCycle{ { // Just create everything normally, and don't validate it. - mode: plans.NormalMode, + planMode: plans.NormalMode, }, { - mode: plans.DestroyMode, + planMode: plans.DestroyMode, wantAppliedChanges: []stackstate.AppliedChange{ &stackstate.AppliedChangeComponentInstance{ ComponentAddr: mustAbsComponent("component.child"), @@ -848,13 +847,13 @@ func TestApplyDestroy(t *testing.T) { "destroy-with-for-each-dependency": { path: path.Join("with-single-input-and-output", "for-each-dependency"), description: "tests destroy operations with for-each dependencies", - cycles: []cycle{ + cycles: []TestCycle{ { // Just create everything normally, and don't validate it. - mode: plans.NormalMode, + planMode: plans.NormalMode, }, { - mode: plans.DestroyMode, + planMode: plans.DestroyMode, wantAppliedChanges: []stackstate.AppliedChange{ &stackstate.AppliedChangeComponentInstance{ ComponentAddr: mustAbsComponent("component.child"), @@ -891,9 +890,7 @@ func TestApplyDestroy(t *testing.T) { } for name, tc := range tcs { t.Run(name, func(t *testing.T) { - ctx := context.Background() - cfg := loadMainBundleConfigForTest(t, tc.path) lock := depsfile.NewLocks() lock.SetProvider( @@ -908,123 +905,28 @@ func TestApplyDestroy(t *testing.T) { store = stacks_testing_provider.NewResourceStore() } - state := tc.state - for ix, cycle := range tc.cycles { - - planReq := PlanRequest{ - PlanMode: cycle.mode, - - Config: cfg, - ProviderFactories: map[addrs.Provider]providers.Factory{ - addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { - return stacks_testing_provider.NewProviderWithData(t, store), nil - }, + testContext := TestContext{ + timestamp: &fakePlanTimestamp, + config: loadMainBundleConfigForTest(t, tc.path), + providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProviderWithData(t, store), nil }, - DependencyLocks: *lock, - ForcePlanTimestamp: &fakePlanTimestamp, - InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue { - inputs := make(map[stackaddrs.InputVariable]ExternalInputValue, len(cycle.inputs)) - for k, v := range cycle.inputs { - inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{Value: v} - } - return inputs - }(), - - PrevState: state, - } - - planChangesCh := make(chan stackplan.PlannedChange) - planDiagsCh := make(chan tfdiags.Diagnostic) - planResp := PlanResponse{ - PlannedChanges: planChangesCh, - Diagnostics: planDiagsCh, - } - - go Plan(ctx, &planReq, &planResp) - planChanges, planDiags := collectPlanOutput(planChangesCh, planDiagsCh) - expectDiagnosticsForTest(t, planDiags, cycle.wantPlannedDiags...) + }, + dependencyLocks: *lock, + } - if cycle.wantPlannedChanges != nil { - // nil indicates skip this check, empty slice indicates no changes expected. - sort.SliceStable(planChanges, func(i, j int) bool { - return plannedChangeSortKey(planChanges[i]) < plannedChangeSortKey(planChanges[j]) + state := tc.state + for ix, cycle := range tc.cycles { + t.Run(strconv.FormatInt(int64(ix), 10), func(t *testing.T) { + var plan *stackplan.Plan + t.Run("plan", func(t *testing.T) { + plan = testContext.Plan(t, ctx, state, cycle) }) - if diff := cmp.Diff(cycle.wantPlannedChanges, planChanges, changesCmpOpts); diff != "" { - t.Fatalf("wrong planned changes %d\n%s", ix, diff) - } - } - - planLoader := stackplan.NewLoader() - for _, change := range planChanges { - proto, err := change.PlannedChangeProto() - if err != nil { - t.Fatal(err) - } - - for _, rawMsg := range proto.Raw { - err = planLoader.AddRaw(rawMsg) - if err != nil { - t.Fatal(err) - } - } - } - plan, err := planLoader.Plan() - if err != nil { - t.Fatal(err) - } - - applyReq := ApplyRequest{ - Config: cfg, - Plan: plan, - ProviderFactories: map[addrs.Provider]providers.Factory{ - addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { - return stacks_testing_provider.NewProviderWithData(t, store), nil - }, - }, - DependencyLocks: *lock, - } - - applyChangesCh := make(chan stackstate.AppliedChange) - applyDiagsCh := make(chan tfdiags.Diagnostic) - applyResp := ApplyResponse{ - AppliedChanges: applyChangesCh, - Diagnostics: applyDiagsCh, - } - - go Apply(ctx, &applyReq, &applyResp) - applyChanges, applyDiags := collectApplyOutput(applyChangesCh, applyDiagsCh) - expectDiagnosticsForTest(t, applyDiags, cycle.wantAppliedDiags...) - - if cycle.wantAppliedChanges != nil { - // nil indicates skip this check, empty slice indicates no changes expected. - sort.SliceStable(applyChanges, func(i, j int) bool { - return appliedChangeSortKey(applyChanges[i]) < appliedChangeSortKey(applyChanges[j]) + t.Run("apply", func(t *testing.T) { + state = testContext.Apply(t, ctx, plan, cycle) }) - if diff := cmp.Diff(cycle.wantAppliedChanges, applyChanges, changesCmpOpts); diff != "" { - t.Fatalf("wrong applied changes %d\n%s", ix, diff) - } - } - - stateLoader := stackstate.NewLoader() - for _, change := range applyChanges { - proto, err := change.AppliedChangeProto() - if err != nil { - t.Fatal(err) - } - - for _, rawMsg := range proto.Raw { - if rawMsg.Value == nil { - // This is a removal notice, so we don't need to add it to the - // state. - continue - } - err = stateLoader.AddRaw(rawMsg.Key, rawMsg.Value) - if err != nil { - t.Fatal(err) - } - } - } - state = stateLoader.State() + }) } }) diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index 92847611d8..b1bbdb4d51 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -4,11 +4,14 @@ package stackruntime import ( + "context" "crypto/sha256" "encoding/json" "fmt" + "sort" "strings" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-slug/sourceaddrs" @@ -17,7 +20,9 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/stacks/stackaddrs" "github.com/hashicorp/terraform/internal/stacks/stackconfig" "github.com/hashicorp/terraform/internal/stacks/stackplan" @@ -28,6 +33,200 @@ import ( // This file has helper functions used by other tests. It doesn't contain any // test cases of its own. +// TestContext contains all the information shared across multiple operations +// in a single test. +type TestContext struct { + // timestamp is the timestamp that should be applied for this test. + timestamp *time.Time + + // config is the config to use for this test. + config *stackconfig.Config + + // providers are the providers that should be available within this test. + providers map[addrs.Provider]providers.Factory + + // dependencyLocks is the locks file that should be used for this test. + dependencyLocks depsfile.Locks +} + +// TestCycle defines a single plan / apply cycle that should be performed within +// a test. +type TestCycle struct { + + // Validate options + + wantValidateDiags tfdiags.Diagnostics + + // Plan options + + planMode plans.Mode + planInputs map[string]cty.Value + wantPlannedChanges []stackplan.PlannedChange + wantPlannedDiags tfdiags.Diagnostics + + // Apply options + + applyInputs map[string]cty.Value + wantAppliedChanges []stackstate.AppliedChange + wantAppliedDiags tfdiags.Diagnostics +} + +func (tc TestContext) Validate(t *testing.T, ctx context.Context, cycle TestCycle) { + t.Helper() + + gotDiags := Validate(ctx, &ValidateRequest{ + Config: tc.config, + ProviderFactories: tc.providers, + DependencyLocks: tc.dependencyLocks, + ExperimentsAllowed: true, + }) + validateDiags(t, cycle.wantValidateDiags, gotDiags) +} + +func (tc TestContext) Plan(t *testing.T, ctx context.Context, state *stackstate.State, cycle TestCycle) *stackplan.Plan { + t.Helper() + + request := PlanRequest{ + PlanMode: cycle.planMode, + Config: tc.config, + PrevState: state, + InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue { + inputs := make(map[stackaddrs.InputVariable]ExternalInputValue, len(cycle.planInputs)) + for k, v := range cycle.planInputs { + inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{Value: v} + } + return inputs + }(), + ProviderFactories: tc.providers, + DependencyLocks: tc.dependencyLocks, + ForcePlanTimestamp: tc.timestamp, + ExperimentsAllowed: true, + } + + changesCh := make(chan stackplan.PlannedChange) + diagsCh := make(chan tfdiags.Diagnostic) + response := PlanResponse{ + PlannedChanges: changesCh, + Diagnostics: diagsCh, + } + + go Plan(ctx, &request, &response) + changes, diags := collectPlanOutput(changesCh, diagsCh) + validateDiags(t, cycle.wantPlannedDiags, diags) + + if cycle.wantPlannedChanges != nil { + // if this is nil (as opposed to empty) then we don't validate the + // returned changes. + + sort.SliceStable(changes, func(i, j int) bool { + return plannedChangeSortKey(changes[i]) < plannedChangeSortKey(changes[j]) + }) + if diff := cmp.Diff(cycle.wantPlannedChanges, changes, changesCmpOpts); len(diff) > 0 { + t.Errorf("wrong planned changes\n%s", diff) + } + } + + planLoader := stackplan.NewLoader() + for _, change := range changes { + proto, err := change.PlannedChangeProto() + if err != nil { + t.Fatal(err) + } + + for _, rawMsg := range proto.Raw { + err = planLoader.AddRaw(rawMsg) + if err != nil { + t.Fatal(err) + } + } + } + plan, err := planLoader.Plan() + if err != nil { + t.Fatal(err) + } + + return plan +} + +func (tc TestContext) Apply(t *testing.T, ctx context.Context, plan *stackplan.Plan, cycle TestCycle) *stackstate.State { + t.Helper() + + request := ApplyRequest{ + Config: tc.config, + Plan: plan, + InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue { + inputs := make(map[stackaddrs.InputVariable]ExternalInputValue, len(cycle.applyInputs)) + for k, v := range cycle.applyInputs { + inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{Value: v} + } + return inputs + }(), + ProviderFactories: tc.providers, + ExperimentsAllowed: true, + DependencyLocks: tc.dependencyLocks, + } + + changesCh := make(chan stackstate.AppliedChange) + diagsCh := make(chan tfdiags.Diagnostic) + response := ApplyResponse{ + AppliedChanges: changesCh, + Diagnostics: diagsCh, + } + + go Apply(ctx, &request, &response) + changes, diags := collectApplyOutput(changesCh, diagsCh) + validateDiags(t, cycle.wantAppliedDiags, diags) + + if cycle.wantAppliedChanges != nil { + // nil indicates skip this check, empty slice indicates no changes expected. + sort.SliceStable(changes, func(i, j int) bool { + return appliedChangeSortKey(changes[i]) < appliedChangeSortKey(changes[j]) + }) + if diff := cmp.Diff(cycle.wantAppliedChanges, changes, changesCmpOpts); diff != "" { + t.Errorf("wrong applied changes\n%s", diff) + } + } + + stateLoader := stackstate.NewLoader() + for _, change := range changes { + proto, err := change.AppliedChangeProto() + if err != nil { + t.Fatal(err) + } + + for _, rawMsg := range proto.Raw { + if rawMsg.Value == nil { + // This is a removal notice, so we don't need to add it to the + // state. + continue + } + err = stateLoader.AddRaw(rawMsg.Key, rawMsg.Value) + if err != nil { + t.Fatal(err) + } + } + } + return stateLoader.State() +} + +func initDiags(cb func(diags tfdiags.Diagnostics) tfdiags.Diagnostics) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + return cb(diags) +} + +func validateDiags(t *testing.T, wantDiags, gotDiags tfdiags.Diagnostics) { + t.Helper() + + sort.SliceStable(gotDiags, diagnosticSortFunc(gotDiags)) + sort.SliceStable(wantDiags, diagnosticSortFunc(wantDiags)) + + gotDiags = gotDiags.ForRPC() + wantDiags = wantDiags.ForRPC() + if diff := cmp.Diff(wantDiags, gotDiags); len(diff) > 0 { + t.Errorf("wrong diagnostics\n%s", diff) + } +} + // loadConfigForTest is a test helper that tries to open bundleRoot as a // source bundle, and then if successful tries to load the given source address // from it as a stack configuration. If any part of the operation fails then diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 59cd943e82..16f4c2824e 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -58,17 +58,8 @@ func TestPlan_valid(t *testing.T) { // We've added this test before the implementation was ready. t.SkipNow() } - ctx := context.Background() - cfg := loadMainBundleConfigForTest(t, name) - fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") - if err != nil { - t.Fatal(err) - } - - changesCh := make(chan stackplan.PlannedChange, 8) - diagsCh := make(chan tfdiags.Diagnostic, 2) lock := depsfile.NewLocks() lock.SetProvider( addrs.NewDefaultProvider("testing"), @@ -82,9 +73,15 @@ func TestPlan_valid(t *testing.T) { providerreqs.MustParseVersionConstraints("=0.0.0"), providerreqs.PreferredHashes([]providerreqs.Hash{}), ) - req := PlanRequest{ - Config: cfg, - ProviderFactories: map[addrs.Provider]providers.Factory{ + + fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") + if err != nil { + t.Fatal(err) + } + + testContext := TestContext{ + config: loadMainBundleConfigForTest(t, name), + providers: map[addrs.Provider]providers.Factory{ // We support both hashicorp/testing and // terraform.io/builtin/testing as providers. This lets us // test the provider aliasing feature. Both providers @@ -101,40 +98,16 @@ func TestPlan_valid(t *testing.T) { return stacks_testing_provider.NewProvider(t), nil }, }, - DependencyLocks: *lock, - InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue { - inputs := map[stackaddrs.InputVariable]ExternalInputValue{} - for k, v := range tc.planInputVars { - inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{ - Value: v, - } - } - return inputs - }(), - ForcePlanTimestamp: &fakePlanTimestamp, - } - resp := PlanResponse{ - PlannedChanges: changesCh, - Diagnostics: diagsCh, + dependencyLocks: *lock, + timestamp: &fakePlanTimestamp, } - go Plan(ctx, &req, &resp) - _, diags := collectPlanOutput(changesCh, diagsCh) - - // We don't care about the planned changes here, just the - // diagnostics. - - // The following will fail the test if there are any error - // diagnostics. - reportDiagnosticsForTest(t, diags) - - // We also want to fail if there are just warnings, since the - // configurations here are supposed to be totally problem-free. - if len(diags) != 0 { - // reportDiagnosticsForTest already showed the diagnostics in - // the log - t.FailNow() + cycle := TestCycle{ + planInputs: tc.planInputVars, + wantPlannedChanges: nil, // don't care about the planned changes in this test. + wantPlannedDiags: nil, // should return no diagnostics. } + testContext.Plan(t, ctx, nil, cycle) }) } } @@ -158,17 +131,8 @@ func TestPlan_invalid(t *testing.T) { // We've added this test before the implementation was ready. t.SkipNow() } - ctx := context.Background() - cfg := loadMainBundleConfigForTest(t, name) - - fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") - if err != nil { - t.Fatal(err) - } - changesCh := make(chan stackplan.PlannedChange, 8) - diagsCh := make(chan tfdiags.Diagnostic, 2) lock := depsfile.NewLocks() lock.SetProvider( addrs.NewDefaultProvider("testing"), @@ -176,9 +140,15 @@ func TestPlan_invalid(t *testing.T) { providerreqs.MustParseVersionConstraints("=0.0.0"), providerreqs.PreferredHashes([]providerreqs.Hash{}), ) - req := PlanRequest{ - Config: cfg, - ProviderFactories: map[addrs.Provider]providers.Factory{ + + fakePlanTimestamp, err := time.Parse(time.RFC3339, "1991-08-25T20:57:08Z") + if err != nil { + t.Fatal(err) + } + + testContext := TestContext{ + config: loadMainBundleConfigForTest(t, name), + providers: map[addrs.Provider]providers.Factory{ // We support both hashicorp/testing and // terraform.io/builtin/testing as providers. This lets us // test the provider aliasing feature. Both providers @@ -190,31 +160,16 @@ func TestPlan_invalid(t *testing.T) { return stacks_testing_provider.NewProvider(t), nil }, }, - DependencyLocks: *lock, - InputValues: func() map[stackaddrs.InputVariable]ExternalInputValue { - inputs := map[stackaddrs.InputVariable]ExternalInputValue{} - for k, v := range tc.planInputVars { - inputs[stackaddrs.InputVariable{Name: k}] = ExternalInputValue{ - Value: v, - } - } - return inputs - }(), - ForcePlanTimestamp: &fakePlanTimestamp, + dependencyLocks: *lock, + timestamp: &fakePlanTimestamp, } - resp := PlanResponse{ - PlannedChanges: changesCh, - Diagnostics: diagsCh, - } - - go Plan(ctx, &req, &resp) - _, gotDiags := collectPlanOutput(changesCh, diagsCh) - wantDiags := tc.diags() - sort.SliceStable(gotDiags, diagnosticSortFunc(gotDiags)) - if diff := cmp.Diff(wantDiags.ForRPC(), gotDiags.ForRPC()); diff != "" { - t.Errorf("wrong diagnostics\n%s", diff) + cycle := TestCycle{ + planInputs: tc.planInputVars, + wantPlannedChanges: nil, // don't care about the planned changes in this test. + wantPlannedDiags: tc.diags(), } + testContext.Plan(t, ctx, nil, cycle) }) } } diff --git a/internal/stacks/stackruntime/validate_test.go b/internal/stacks/stackruntime/validate_test.go index 173ad14251..39cce9af4f 100644 --- a/internal/stacks/stackruntime/validate_test.go +++ b/internal/stacks/stackruntime/validate_test.go @@ -6,11 +6,8 @@ package stackruntime import ( "context" "path/filepath" - "sort" "testing" - "time" - "github.com/google/go-cmp/cmp" "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" @@ -338,9 +335,8 @@ func TestValidate_valid(t *testing.T) { // We've added this test before the implementation was ready. t.SkipNow() } - ctx := context.Background() - cfg := loadMainBundleConfigForTest(t, name) + lock := depsfile.NewLocks() lock.SetProvider( addrs.NewDefaultProvider("testing"), @@ -355,9 +351,9 @@ func TestValidate_valid(t *testing.T) { providerreqs.PreferredHashes([]providerreqs.Hash{}), ) - diags := Validate(ctx, &ValidateRequest{ - Config: cfg, - ProviderFactories: map[addrs.Provider]providers.Factory{ + testContext := TestContext{ + config: loadMainBundleConfigForTest(t, name), + providers: map[addrs.Provider]providers.Factory{ // We support both hashicorp/testing and // terraform.io/builtin/testing as providers. This lets us // test the provider aliasing feature. Both providers @@ -374,20 +370,11 @@ func TestValidate_valid(t *testing.T) { return stacks_testing_provider.NewProvider(t), nil }, }, - DependencyLocks: *lock, - }) - - // The following will fail the test if there are any error - // diagnostics. - reportDiagnosticsForTest(t, diags) - - // We also want to fail if there are just warnings, since the - // configurations here are supposed to be totally problem-free. - if len(diags) != 0 { - // reportDiagnosticsForTest already showed the diagnostics in - // the log - t.FailNow() + dependencyLocks: *lock, } + + cycle := TestCycle{} // empty, as we expect no diagnostics + testContext.Validate(t, ctx, cycle) }) } } @@ -399,9 +386,7 @@ func TestValidate_invalid(t *testing.T) { // We've added this test before the implementation was ready. t.SkipNow() } - ctx := context.Background() - cfg := loadMainBundleConfigForTest(t, name) lock := depsfile.NewLocks() lock.SetProvider( @@ -410,10 +395,16 @@ func TestValidate_invalid(t *testing.T) { providerreqs.MustParseVersionConstraints("=0.0.0"), providerreqs.PreferredHashes([]providerreqs.Hash{}), ) + lock.SetProvider( + addrs.NewDefaultProvider("other"), + providerreqs.MustParseVersion("0.0.0"), + providerreqs.MustParseVersionConstraints("=0.0.0"), + providerreqs.PreferredHashes([]providerreqs.Hash{}), + ) - gotDiags := Validate(ctx, &ValidateRequest{ - Config: cfg, - ProviderFactories: map[addrs.Provider]providers.Factory{ + testContext := TestContext{ + config: loadMainBundleConfigForTest(t, name), + providers: map[addrs.Provider]providers.Factory{ // We support both hashicorp/testing and // terraform.io/builtin/testing as providers. This lets us // test the provider aliasing feature. Both providers @@ -424,128 +415,81 @@ func TestValidate_invalid(t *testing.T) { addrs.NewBuiltInProvider("testing"): func() (providers.Interface, error) { return stacks_testing_provider.NewProvider(t), nil }, + // We also support an "other" provider out of the box to + // test the provider aliasing feature. + addrs.NewDefaultProvider("other"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, }, - DependencyLocks: *lock, - }).ForRPC() - - // Let's make the returned diagnostics stable so that we can - // compare them easily. - sort.SliceStable(gotDiags, diagnosticSortFunc(gotDiags)) - - wantDiags := tc.diags().ForRPC() - - if diff := cmp.Diff(wantDiags, gotDiags); diff != "" { - t.Errorf("wrong diagnostics\n%s", diff) + dependencyLocks: *lock, } + testContext.Validate(t, ctx, TestCycle{ + wantValidateDiags: tc.diags(), + }) }) } } -func TestValidate_embeddedStackSelfRef(t *testing.T) { - ctx := context.Background() - - // One possible failure mode for this test is to deadlock itself if - // our deadlock detection is incorrect, so we'll try to make it bail - // if it runs too long. - ctx, cancel := context.WithTimeout(ctx, time.Second*5) - defer cancel() - - ctx, span := tracer.Start(ctx, "TestValidate_embeddedStackSelfRef") - defer span.End() - - cfg := loadMainBundleConfigForTest(t, "validate-embedded-stack-selfref") - - gotDiags := Validate(ctx, &ValidateRequest{ - Config: cfg, - }) - - // We'll normalize the diagnostics to be of consistent underlying type - // using ForRPC, so that we can easily diff them; we don't actually care - // about which underlying implementation is in use. - gotDiags = gotDiags.ForRPC() - var wantDiags tfdiags.Diagnostics - wantDiags = wantDiags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Self-dependent items in configuration", - `The following items in your configuration form a circular dependency chain through their references: +func TestValidate(t *testing.T) { + tcs := map[string]struct { + path string + providers map[addrs.Provider]providers.Factory + locks *depsfile.Locks + wantDiags tfdiags.Diagnostics + }{ + "embedded-stack-selfref": { + path: "validate-embedded-stack-selfref", + wantDiags: initDiags(func(diags tfdiags.Diagnostics) tfdiags.Diagnostics { + return diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Self-dependent items in configuration", + `The following items in your configuration form a circular dependency chain through their references: - stack.a collected outputs - stack.a.output.a value - stack.a inputs Terraform uses references to decide a suitable order for performing operations, so configuration items may not refer to their own results either directly or indirectly.`, - )) - wantDiags = wantDiags.ForRPC() - - if diff := cmp.Diff(wantDiags, gotDiags); diff != "" { - t.Errorf("wrong diagnostics\n%s", diff) - } -} - -func TestValidate_missing_provider_from_lockfile(t *testing.T) { - ctx := context.Background() - cfg := loadMainBundleConfigForTest(t, filepath.Join("with-single-input", "input-from-component")) - lock := depsfile.NewLocks() - - diags := Validate(ctx, &ValidateRequest{ - Config: cfg, - ProviderFactories: map[addrs.Provider]providers.Factory{ - // We support both hashicorp/testing and - // terraform.io/builtin/testing as providers. This lets us - // test the provider aliasing feature. Both providers - // support the same set of resources and data sources. - addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { - return stacks_testing_provider.NewProvider(t), nil - }, - addrs.NewBuiltInProvider("testing"): func() (providers.Interface, error) { - return stacks_testing_provider.NewProvider(t), nil + )) + }), + }, + "missing-provider-from-lockfile": { + path: filepath.Join("with-single-input", "input-from-component"), + providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { + return stacks_testing_provider.NewProvider(t), nil + }, }, + locks: depsfile.NewLocks(), // deliberately empty + wantDiags: initDiags(func(diags tfdiags.Diagnostics) tfdiags.Diagnostics { + return diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Provider missing from lockfile", + Detail: "Provider \"registry.terraform.io/hashicorp/testing\" is not in the lockfile. This provider must be in the lockfile to be used in the configuration. Please run `tfstacks providers lock` to update the lockfile and run this operation again with an updated configuration.", + Subject: &hcl.Range{ + Filename: "git::https://example.com/test.git//with-single-input/input-from-component/input-from-component.tfstack.hcl", + Start: hcl.Pos{Line: 8, Column: 1, Byte: 98}, + End: hcl.Pos{Line: 8, Column: 29, Byte: 126}, + }, + }) + }), }, - DependencyLocks: *lock, - }) - - if len(diags) != 1 { - t.Fatalf("expected exactly one diagnostic, got %d", len(diags)) - } - - diag := diags[0] - if diag.Severity() != tfdiags.Error { - t.Fatalf("expected error diagnostic, got %s", diag.Severity()) - } - - if diag.Description().Summary != "Provider missing from lockfile" { - t.Fatalf("expected diagnostic summary 'Provider missing from lockfile', got %q", diag.Description().Summary) - } - - if diag.Description().Detail != "Provider \"registry.terraform.io/hashicorp/testing\" is not in the lockfile. This provider must be in the lockfile to be used in the configuration. Please run `tfstacks providers lock` to update the lockfile and run this operation again with an updated configuration." { - t.Fatalf("expected diagnostic detail to be a specific message, got %q", diag.Description().Detail) - } -} - -func TestValidate_impliedProviderTypes(t *testing.T) { - - tcs := []struct { - directory string - providers map[addrs.Provider]providers.Factory - wantDiags func() tfdiags.Diagnostics - }{ - { - directory: "with-hashicorp-provider", + "implied-provider-type-with-hashicorp-provider": { + path: filepath.Join("legacy-module", "with-hashicorp-provider"), providers: map[addrs.Provider]providers.Factory{ addrs.NewDefaultProvider("testing"): func() (providers.Interface, error) { return stacks_testing_provider.NewProvider(t), nil }, }, }, - { - directory: "with-non-hashicorp-provider", + "implied-provider-type-with-non-hashicorp-provider": { + path: filepath.Join("legacy-module", "with-non-hashicorp-provider"), providers: map[addrs.Provider]providers.Factory{ addrs.NewProvider(addrs.DefaultProviderRegistryHost, "other", "testing"): func() (providers.Interface, error) { return stacks_testing_provider.NewProvider(t), nil }, }, - wantDiags: func() tfdiags.Diagnostics { - var diags tfdiags.Diagnostics - diags = diags.Append(&hcl.Diagnostic{ + wantDiags: initDiags(func(diags tfdiags.Diagnostics) tfdiags.Diagnostics { + return diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid provider configuration", Detail: "The provider configuration slot \"testing\" requires a configuration for provider \"registry.terraform.io/hashicorp/testing\", not for provider \"registry.terraform.io/other/testing\"." + @@ -556,40 +500,37 @@ func TestValidate_impliedProviderTypes(t *testing.T) { End: hcl.Pos{Line: 21, Column: 39, Byte: 471}, }, }) - return diags - }, + }), }, } - for _, tc := range tcs { - t.Run(tc.directory, func(t *testing.T) { - + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { ctx := context.Background() - lock := depsfile.NewLocks() - for addr := range tc.providers { - lock.SetProvider( - addr, - providerreqs.MustParseVersion("0.0.0"), - providerreqs.MustParseVersionConstraints("=0.0.0"), - providerreqs.PreferredHashes([]providerreqs.Hash{}), - ) + ctx, span := tracer.Start(ctx, name) + defer span.End() + + locks := tc.locks + if locks == nil { + locks = depsfile.NewLocks() + for addr := range tc.providers { + locks.SetProvider( + addr, + providerreqs.MustParseVersion("0.0.0"), + providerreqs.MustParseVersionConstraints("=0.0.0"), + providerreqs.PreferredHashes([]providerreqs.Hash{}), + ) + } } - cfg := loadMainBundleConfigForTest(t, filepath.Join("legacy-module", tc.directory)) - gotDiags := Validate(ctx, &ValidateRequest{ - Config: cfg, - ProviderFactories: tc.providers, - DependencyLocks: *lock, - }).ForRPC() - - wantDiags := tfdiags.Diagnostics{}.ForRPC() - if tc.wantDiags != nil { - wantDiags = tc.wantDiags().ForRPC() - } - - if diff := cmp.Diff(wantDiags, gotDiags); diff != "" { - t.Errorf("wrong diagnostics\n%s", diff) + testContext := TestContext{ + config: loadMainBundleConfigForTest(t, tc.path), + providers: tc.providers, + dependencyLocks: *locks, } + testContext.Validate(t, ctx, TestCycle{ + wantValidateDiags: tc.wantDiags, + }) }) } }