diff --git a/.changes/unreleased/ENHANCEMENTS-20240503-161709.yaml b/.changes/unreleased/ENHANCEMENTS-20240503-161709.yaml new file mode 100644 index 000000000..dff8d4adf --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20240503-161709.yaml @@ -0,0 +1,6 @@ +kind: ENHANCEMENTS +body: 'helper/resource: Added `(TestCase).AdditionalCLIOptions` with `AllowDeferral` + option for plan and apply commands.' +time: 2024-05-03T16:17:09.64792-04:00 +custom: + Issue: "331" diff --git a/.changes/unreleased/FEATURES-20240503-161531.yaml b/.changes/unreleased/FEATURES-20240503-161531.yaml new file mode 100644 index 000000000..edd8c036f --- /dev/null +++ b/.changes/unreleased/FEATURES-20240503-161531.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'plancheck: Added `ExpectDeferredChange` and `ExpectNoDeferredChanges` checks + for experimental deferred action support.' +time: 2024-05-03T16:15:31.03438-04:00 +custom: + Issue: "331" diff --git a/.changes/unreleased/FEATURES-20240503-161802.yaml b/.changes/unreleased/FEATURES-20240503-161802.yaml new file mode 100644 index 000000000..a06f5f78c --- /dev/null +++ b/.changes/unreleased/FEATURES-20240503-161802.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'tfversion: Added `SkipIfNotPrerelease` version check for testing experimental + features of prerelease Terraform builds.' +time: 2024-05-03T16:18:02.132794-04:00 +custom: + Issue: "331" diff --git a/go.mod b/go.mod index c234fa239..a805261e6 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/hashicorp/logutils v1.0.0 github.com/hashicorp/terraform-exec v0.21.0 github.com/hashicorp/terraform-json v0.22.1 - github.com/hashicorp/terraform-plugin-go v0.22.2 + github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 github.com/mitchellh/go-testing-interface v1.14.1 @@ -56,5 +56,5 @@ require ( google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect google.golang.org/grpc v1.63.2 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/protobuf v1.34.0 // indirect ) diff --git a/go.sum b/go.sum index 5ce8cbdeb..6d381066e 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,8 @@ github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVW github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= github.com/hashicorp/terraform-json v0.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7orfb5Ltvec= github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= -github.com/hashicorp/terraform-plugin-go v0.22.2 h1:5o8uveu6eZUf5J7xGPV0eY0TPXg3qpmwX9sce03Bxnc= -github.com/hashicorp/terraform-plugin-go v0.22.2/go.mod h1:drq8Snexp9HsbFZddvyLHN6LuWHHndSQg+gV+FPkcIM= +github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= +github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 h1:qHprzXy/As0rxedphECBEQAh3R4yp6pKksKHcqZx5G8= @@ -200,8 +200,8 @@ google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= +google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/helper/resource/additional_cli_options.go b/helper/resource/additional_cli_options.go new file mode 100644 index 000000000..62578edef --- /dev/null +++ b/helper/resource/additional_cli_options.go @@ -0,0 +1,26 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package resource + +// AdditionalCLIOptions allows an intentionally limited set of options to be passed +// to the Terraform CLI when executing test steps. +type AdditionalCLIOptions struct { + // Apply represents options to be passed to the `terraform apply` command. + Apply ApplyOptions + + // Plan represents options to be passed to the `terraform plan` command. + Plan PlanOptions +} + +// ApplyOptions represents options to be passed to the `terraform apply` command. +type ApplyOptions struct { + // AllowDeferral will pass the experimental `-allow-deferral` flag to the apply command. + AllowDeferral bool +} + +// PlanOptions represents options to be passed to the `terraform plan` command. +type PlanOptions struct { + // AllowDeferral will pass the experimental `-allow-deferral` flag to the plan command. + AllowDeferral bool +} diff --git a/helper/resource/testing.go b/helper/resource/testing.go index f98abda39..9e1961a46 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -441,6 +441,10 @@ type TestCase struct { // set to "1", to persist any working directory files. Otherwise, this directory is // automatically cleaned up at the end of the TestCase. WorkingDir string + + // AdditionalCLIOptions allows an intentionally limited set of options to be passed + // to the Terraform CLI when executing test steps. + AdditionalCLIOptions *AdditionalCLIOptions } // ExternalProvider holds information about third-party providers that should diff --git a/helper/resource/testing_new_config.go b/helper/resource/testing_new_config.go index 9747eaf6e..1456f7fba 100644 --- a/helper/resource/testing_new_config.go +++ b/helper/resource/testing_new_config.go @@ -107,6 +107,11 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint if step.Destroy { opts = append(opts, tfexec.Destroy(true)) } + + if c.AdditionalCLIOptions != nil && c.AdditionalCLIOptions.Plan.AllowDeferral { + opts = append(opts, tfexec.AllowDeferral(true)) + } + return wd.CreatePlan(ctx, opts...) }, wd, providers) if err != nil { @@ -168,7 +173,13 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint // Apply the diff, creating real resources err = runProviderCommand(ctx, t, func() error { - return wd.Apply(ctx) + var opts []tfexec.ApplyOption + + if c.AdditionalCLIOptions != nil && c.AdditionalCLIOptions.Apply.AllowDeferral { + opts = append(opts, tfexec.AllowDeferral(true)) + } + + return wd.Apply(ctx, opts...) }, wd, providers) if err != nil { if step.Destroy { @@ -238,6 +249,11 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint if step.Destroy { opts = append(opts, tfexec.Destroy(true)) } + + if c.AdditionalCLIOptions != nil && c.AdditionalCLIOptions.Plan.AllowDeferral { + opts = append(opts, tfexec.AllowDeferral(true)) + } + return wd.CreatePlan(ctx, opts...) }, wd, providers) if err != nil { @@ -302,6 +318,11 @@ func testStepNewConfig(ctx context.Context, t testing.T, c TestCase, wd *plugint opts = append(opts, tfexec.Refresh(false)) } } + + if c.AdditionalCLIOptions != nil && c.AdditionalCLIOptions.Plan.AllowDeferral { + opts = append(opts, tfexec.AllowDeferral(true)) + } + return wd.CreatePlan(ctx, opts...) }, wd, providers) if err != nil { diff --git a/internal/plugintest/working_dir.go b/internal/plugintest/working_dir.go index 2c8888d72..0cff33408 100644 --- a/internal/plugintest/working_dir.go +++ b/internal/plugintest/working_dir.go @@ -257,8 +257,9 @@ func (wd *WorkingDir) CreatePlan(ctx context.Context, opts ...tfexec.PlanOption) // successfully and the saved plan has not been cleared in the meantime then // this will apply the saved plan. Otherwise, it will implicitly create a new // plan and apply it. -func (wd *WorkingDir) Apply(ctx context.Context) error { +func (wd *WorkingDir) Apply(ctx context.Context, opts ...tfexec.ApplyOption) error { args := []tfexec.ApplyOption{tfexec.Reattach(wd.reattachInfo), tfexec.Refresh(false)} + args = append(args, opts...) if wd.HasSavedPlan() { args = append(args, tfexec.DirOrPlan(PlanFileName)) } diff --git a/internal/testing/testsdk/providerserver/providerserver.go b/internal/testing/testsdk/providerserver/providerserver.go index 1e0dd017d..4cc3f84ad 100644 --- a/internal/testing/testsdk/providerserver/providerserver.go +++ b/internal/testing/testsdk/providerserver/providerserver.go @@ -58,6 +58,18 @@ type ProviderServer struct { Provider provider.Provider } +func (s ProviderServer) CallFunction(ctx context.Context, req *tfprotov6.CallFunctionRequest) (*tfprotov6.CallFunctionResponse, error) { + return &tfprotov6.CallFunctionResponse{}, nil +} + +func (s ProviderServer) GetFunctions(ctx context.Context, req *tfprotov6.GetFunctionsRequest) (*tfprotov6.GetFunctionsResponse, error) { + return &tfprotov6.GetFunctionsResponse{}, nil +} + +func (s ProviderServer) MoveResourceState(ctx context.Context, req *tfprotov6.MoveResourceStateRequest) (*tfprotov6.MoveResourceStateResponse, error) { + return &tfprotov6.MoveResourceStateResponse{}, nil +} + func (s ProviderServer) GetMetadata(ctx context.Context, request *tfprotov6.GetMetadataRequest) (*tfprotov6.GetMetadataResponse, error) { resp := &tfprotov6.GetMetadataResponse{ ServerCapabilities: &tfprotov6.ServerCapabilities{ @@ -448,6 +460,7 @@ func (s ProviderServer) PlanResourceChange(ctx context.Context, req *tfprotov6.P resp.Diagnostics = planResp.Diagnostics resp.RequiresReplace = planResp.RequiresReplace + resp.Deferred = planResp.Deferred if len(resp.Diagnostics) > 0 { return resp, nil diff --git a/internal/testing/testsdk/providerserver/providerserver_protov5.go b/internal/testing/testsdk/providerserver/providerserver_protov5.go index 1c945f68f..4e6452b67 100644 --- a/internal/testing/testsdk/providerserver/providerserver_protov5.go +++ b/internal/testing/testsdk/providerserver/providerserver_protov5.go @@ -36,6 +36,20 @@ type Protov5ProviderServer struct { Provider provider.Protov5Provider } +// CallFunction implements tfprotov5.ProviderServer. +func (s Protov5ProviderServer) CallFunction(ctx context.Context, req *tfprotov5.CallFunctionRequest) (*tfprotov5.CallFunctionResponse, error) { + return &tfprotov5.CallFunctionResponse{}, nil +} + +// GetFunctions implements tfprotov5.ProviderServer. +func (s Protov5ProviderServer) GetFunctions(ctx context.Context, req *tfprotov5.GetFunctionsRequest) (*tfprotov5.GetFunctionsResponse, error) { + return &tfprotov5.GetFunctionsResponse{}, nil +} + +func (s Protov5ProviderServer) MoveResourceState(ctx context.Context, req *tfprotov5.MoveResourceStateRequest) (*tfprotov5.MoveResourceStateResponse, error) { + return &tfprotov5.MoveResourceStateResponse{}, nil +} + func (s Protov5ProviderServer) GetMetadata(ctx context.Context, request *tfprotov5.GetMetadataRequest) (*tfprotov5.GetMetadataResponse, error) { return &tfprotov5.GetMetadataResponse{}, nil } diff --git a/internal/testing/testsdk/resource/resource.go b/internal/testing/testsdk/resource/resource.go index f053b135f..5fea34468 100644 --- a/internal/testing/testsdk/resource/resource.go +++ b/internal/testing/testsdk/resource/resource.go @@ -55,6 +55,7 @@ type PlanChangeRequest struct { } type PlanChangeResponse struct { + Deferred *tfprotov6.Deferred Diagnostics []*tfprotov6.Diagnostic PlannedState tftypes.Value RequiresReplace []*tftypes.AttributePath diff --git a/plancheck/deferred_reason.go b/plancheck/deferred_reason.go new file mode 100644 index 000000000..4787a8c3e --- /dev/null +++ b/plancheck/deferred_reason.go @@ -0,0 +1,21 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck + +// DeferredReason is a string stored in the plan file which indicates why Terraform +// is deferring a change for a resource. +type DeferredReason string + +const ( + // DeferredReasonResourceConfigUnknown is used to indicate that the resource configuration + // is partially unknown and the real values need to be known before the change can be planned. + DeferredReasonResourceConfigUnknown DeferredReason = "resource_config_unknown" + + // DeferredReasonProviderConfigUnknown is used to indicate that the provider configuration + // is partially unknown and the real values need to be known before the change can be planned. + DeferredReasonProviderConfigUnknown DeferredReason = "provider_config_unknown" + + // DeferredReasonAbsentPrereq is used to indicate that a hard dependency has not been satisfied. + DeferredReasonAbsentPrereq DeferredReason = "absent_prereq" +) diff --git a/plancheck/expect_deferred_change.go b/plancheck/expect_deferred_change.go new file mode 100644 index 000000000..14310ca31 --- /dev/null +++ b/plancheck/expect_deferred_change.go @@ -0,0 +1,49 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck + +import ( + "context" + "fmt" +) + +var _ PlanCheck = expectDeferredChange{} + +type expectDeferredChange struct { + resourceAddress string + reason DeferredReason +} + +// CheckPlan implements the plan check logic. +func (e expectDeferredChange) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + foundResource := false + + for _, dc := range req.Plan.DeferredChanges { + if dc.ResourceChange == nil || e.resourceAddress != dc.ResourceChange.Address { + continue + } + + if e.reason != DeferredReason(dc.Reason) { + resp.Error = fmt.Errorf("'%s' - expected %q, got deferred reason: %q", dc.ResourceChange.Address, e.reason, dc.Reason) + return + } + + foundResource = true + break + } + + if !foundResource { + resp.Error = fmt.Errorf("%s - No deferred changes found for resource", e.resourceAddress) + return + } +} + +// ExpectDeferredChange returns a plan check that asserts that a given resource will have a +// deferred change in the plan with the given reason. +func ExpectDeferredChange(resourceAddress string, reason DeferredReason) PlanCheck { + return expectDeferredChange{ + resourceAddress: resourceAddress, + reason: reason, + } +} diff --git a/plancheck/expect_deferred_change_test.go b/plancheck/expect_deferred_change_test.go new file mode 100644 index 000000000..05baa0671 --- /dev/null +++ b/plancheck/expect_deferred_change_test.go @@ -0,0 +1,169 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck_test + +import ( + "context" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/providerserver" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func Test_ExpectDeferredChange_Reason_Match(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_9_0), + tfversion.SkipIfNotPrerelease(), + }, + AdditionalCLIOptions: &r.AdditionalCLIOptions{ + Plan: r.PlanOptions{AllowDeferral: true}, + Apply: r.ApplyOptions{AllowDeferral: true}, + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "test": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "test_resource": { + PlanChangeFunc: func(ctx context.Context, req resource.PlanChangeRequest, resp *resource.PlanChangeResponse) { + resp.Deferred = &tfprotov6.Deferred{ + Reason: tfprotov6.DeferredReasonResourceConfigUnknown, + } + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }), + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" {}`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectDeferredChange("test_resource.test", plancheck.DeferredReasonResourceConfigUnknown), + }, + }, + }, + }, + }) +} + +func Test_ExpectDeferredChange_Reason_NoMatch(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_9_0), + tfversion.SkipIfNotPrerelease(), + }, + AdditionalCLIOptions: &r.AdditionalCLIOptions{ + Plan: r.PlanOptions{AllowDeferral: true}, + Apply: r.ApplyOptions{AllowDeferral: true}, + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "test": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "test_resource": { + PlanChangeFunc: func(ctx context.Context, req resource.PlanChangeRequest, resp *resource.PlanChangeResponse) { + resp.Deferred = &tfprotov6.Deferred{ + Reason: tfprotov6.DeferredReasonAbsentPrereq, + } + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }), + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" {}`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectDeferredChange("test_resource.test", plancheck.DeferredReasonProviderConfigUnknown), + }, + }, + ExpectError: regexp.MustCompile(`expected "provider_config_unknown", got deferred reason: "absent_prereq"`), + }, + }, + }) +} + +func Test_ExpectDeferredChange_NoDeferredChanges(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_9_0), + tfversion.SkipIfNotPrerelease(), + }, + AdditionalCLIOptions: &r.AdditionalCLIOptions{ + Plan: r.PlanOptions{AllowDeferral: true}, + Apply: r.ApplyOptions{AllowDeferral: true}, + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "test": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "test_resource": { + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }), + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" {}`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectDeferredChange("test_resource.test", plancheck.DeferredReasonProviderConfigUnknown), + }, + }, + ExpectError: regexp.MustCompile(`No deferred changes found for resource`), + }, + }, + }) +} diff --git a/plancheck/expect_no_deferred_changes.go b/plancheck/expect_no_deferred_changes.go new file mode 100644 index 000000000..726ea6802 --- /dev/null +++ b/plancheck/expect_no_deferred_changes.go @@ -0,0 +1,52 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck + +import ( + "context" + "errors" + "fmt" +) + +var _ PlanCheck = expectNoDeferredChanges{} + +type expectNoDeferredChanges struct{} + +// CheckPlan implements the plan check logic. +func (e expectNoDeferredChanges) CheckPlan(ctx context.Context, req CheckPlanRequest, resp *CheckPlanResponse) { + if len(req.Plan.DeferredChanges) == 0 { + return + } + + var result []error + for _, deferred := range req.Plan.DeferredChanges { + resourceAddress := "unknown" + if deferred.ResourceChange != nil { + resourceAddress = deferred.ResourceChange.Address + } + + result = append(result, fmt.Errorf("expected no deferred changes, but resource %q is deferred with reason: %q", resourceAddress, deferred.Reason)) + } + + resp.Error = errors.Join(result...) + if resp.Error != nil { + return + } + + if req.Plan.Complete == nil { + resp.Error = errors.New("expected plan to be marked as complete, but complete field was not set in plan (nil). This indicates that the plan was created with a version of Terraform older than 1.8, which does not support the complete field.") + return + } + + if !*req.Plan.Complete { + resp.Error = errors.New("expected plan to be marked as complete, but complete was \"false\", indicating that at least one more plan/apply round is needed to converge.") + return + } +} + +// ExpectNoDeferredChanges returns a plan check that asserts that there are no deferred changes +// for any resources in the plan. +func ExpectNoDeferredChanges() PlanCheck { + return expectNoDeferredChanges{} +} diff --git a/plancheck/expect_no_deferred_changes_test.go b/plancheck/expect_no_deferred_changes_test.go new file mode 100644 index 000000000..b3b5bfc23 --- /dev/null +++ b/plancheck/expect_no_deferred_changes_test.go @@ -0,0 +1,198 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package plancheck_test + +import ( + "context" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/providerserver" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func Test_ExpectNoDeferredChange(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_9_0), + tfversion.SkipIfNotPrerelease(), + }, + AdditionalCLIOptions: &r.AdditionalCLIOptions{ + Plan: r.PlanOptions{AllowDeferral: true}, + Apply: r.ApplyOptions{AllowDeferral: true}, + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "test": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "test_resource": { + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + Required: true, + }, + }, + }, + }, + }, + }, + }, + }), + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" { + id = "hello" + }`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNoDeferredChanges(), + }, + }, + }, + }, + }) +} + +func Test_ExpectNoDeferredChange_OneDeferral(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_9_0), + tfversion.SkipIfNotPrerelease(), + }, + AdditionalCLIOptions: &r.AdditionalCLIOptions{ + Plan: r.PlanOptions{AllowDeferral: true}, + Apply: r.ApplyOptions{AllowDeferral: true}, + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "test": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "test_resource": { + PlanChangeFunc: func(ctx context.Context, req resource.PlanChangeRequest, resp *resource.PlanChangeResponse) { + resp.Deferred = &tfprotov6.Deferred{ + Reason: tfprotov6.DeferredReasonResourceConfigUnknown, + } + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }), + }, + Steps: []r.TestStep{ + { + Config: `resource "test_resource" "test" {}`, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNoDeferredChanges(), + }, + }, + ExpectError: regexp.MustCompile(`expected no deferred changes, but resource "test_resource.test" is deferred with reason: "resource_config_unknown"`), + }, + }, + }) +} + +func Test_ExpectNoDeferredChange_MultipleDeferrals(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_9_0), + tfversion.SkipIfNotPrerelease(), + }, + AdditionalCLIOptions: &r.AdditionalCLIOptions{ + Plan: r.PlanOptions{AllowDeferral: true}, + Apply: r.ApplyOptions{AllowDeferral: true}, + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "test": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "test_resource_one": { + PlanChangeFunc: func(ctx context.Context, req resource.PlanChangeRequest, resp *resource.PlanChangeResponse) { + resp.Deferred = &tfprotov6.Deferred{ + Reason: tfprotov6.DeferredReasonResourceConfigUnknown, + } + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + Computed: true, + }, + }, + }, + }, + }, + }, + "test_resource_two": { + PlanChangeFunc: func(ctx context.Context, req resource.PlanChangeRequest, resp *resource.PlanChangeResponse) { + resp.Deferred = &tfprotov6.Deferred{ + Reason: tfprotov6.DeferredReasonAbsentPrereq, + } + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }), + }, + Steps: []r.TestStep{ + { + Config: ` + resource "test_resource_one" "test" {} + + resource "test_resource_two" "test" {} + `, + ConfigPlanChecks: r.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectNoDeferredChanges(), + }, + }, + ExpectError: regexp.MustCompile( + `expected no deferred changes, but resource "test_resource_one.test" is deferred with reason: "resource_config_unknown"\n` + + `expected no deferred changes, but resource "test_resource_two.test" is deferred with reason: "absent_prereq"`, + ), + }, + }, + }) +} diff --git a/tfversion/skip_if_not_prerelease.go b/tfversion/skip_if_not_prerelease.go new file mode 100644 index 000000000..c49400b29 --- /dev/null +++ b/tfversion/skip_if_not_prerelease.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfversion + +import ( + "context" + "fmt" +) + +// SkipIfNotPrerelease will skip (pass) the test if the Terraform CLI +// version does not include prerelease information. This will include builds +// of Terraform that are from source. (e.g. 1.8.0-dev) +func SkipIfNotPrerelease() TerraformVersionCheck { + return skipIfNotPrereleaseCheck{} +} + +// skipIfNotPrereleaseCheck implements the TerraformVersionCheck interface +type skipIfNotPrereleaseCheck struct{} + +// CheckTerraformVersion satisfies the TerraformVersionCheck interface. +func (s skipIfNotPrereleaseCheck) CheckTerraformVersion(ctx context.Context, req CheckTerraformVersionRequest, resp *CheckTerraformVersionResponse) { + if req.TerraformVersion.Prerelease() != "" { + return + } + + resp.Skip = fmt.Sprintf("Terraform CLI version %s is not a prerelease build: skipping test.", req.TerraformVersion) +} diff --git a/tfversion/skip_if_not_prerelease_test.go b/tfversion/skip_if_not_prerelease_test.go new file mode 100644 index 000000000..545017ed0 --- /dev/null +++ b/tfversion/skip_if_not_prerelease_test.go @@ -0,0 +1,94 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfversion_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/providerserver" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + + testinginterface "github.com/mitchellh/go-testing-interface" +) + +func Test_SkipIfNotPrerelease_SkipTest_Stable(t *testing.T) { //nolint:paralleltest + t.Setenv("TF_ACC_TERRAFORM_PATH", "") + t.Setenv("TF_ACC_TERRAFORM_VERSION", "1.8.0") + + r.UnitTest(t, r.TestCase{ + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "test": func() (tfprotov6.ProviderServer, error) { //nolint:unparam // required signature + return nil, nil + }, + }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipIfNotPrerelease(), + }, + Steps: []r.TestStep{ + { + Config: `//non-empty config`, + }, + }, + }) +} + +func Test_SkipIfNotPrerelease_RunTest_Alpha(t *testing.T) { //nolint:paralleltest + t.Setenv("TF_ACC_TERRAFORM_PATH", "") + t.Setenv("TF_ACC_TERRAFORM_VERSION", "1.9.0-alpha20240501") + + r.UnitTest(&testinginterface.RuntimeT{}, r.TestCase{ + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "test": providerserver.NewProviderServer(testprovider.Provider{}), + }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipIfNotPrerelease(), + }, + Steps: []r.TestStep{ + { + Config: `//non-empty config`, + }, + }, + }) +} + +func Test_SkipIfNotPrerelease_RunTest_Beta1(t *testing.T) { //nolint:paralleltest + t.Setenv("TF_ACC_TERRAFORM_PATH", "") + t.Setenv("TF_ACC_TERRAFORM_VERSION", "1.8.0-beta1") + + r.UnitTest(&testinginterface.RuntimeT{}, r.TestCase{ + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "test": providerserver.NewProviderServer(testprovider.Provider{}), + }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipIfNotPrerelease(), + }, + Steps: []r.TestStep{ + { + Config: `//non-empty config`, + }, + }, + }) +} +func Test_SkipIfNotPrerelease_RunTest_RC(t *testing.T) { //nolint:paralleltest + t.Setenv("TF_ACC_TERRAFORM_PATH", "") + t.Setenv("TF_ACC_TERRAFORM_VERSION", "1.8.0-rc2") + + r.UnitTest(&testinginterface.RuntimeT{}, r.TestCase{ + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "test": providerserver.NewProviderServer(testprovider.Provider{}), + }, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipIfNotPrerelease(), + }, + Steps: []r.TestStep{ + { + Config: `//non-empty config`, + }, + }, + }) +}