diff --git a/.changes/unreleased/BREAKING CHANGES-20250404-170656.yaml b/.changes/unreleased/BREAKING CHANGES-20250404-170656.yaml new file mode 100644 index 000000000..aaae02bab --- /dev/null +++ b/.changes/unreleased/BREAKING CHANGES-20250404-170656.yaml @@ -0,0 +1,5 @@ +kind: BREAKING CHANGES +body: 'importstate: `ImportStatePersist` and `ImportStateVerify` are not supported for plannable import (`ImportBlockWith*`) and will return an error' +time: 2025-04-04T17:06:56.900935-04:00 +custom: + Issue: "476" diff --git a/.changes/unreleased/BREAKING CHANGES-20250404-171048.yaml b/.changes/unreleased/BREAKING CHANGES-20250404-171048.yaml new file mode 100644 index 000000000..4c4e57876 --- /dev/null +++ b/.changes/unreleased/BREAKING CHANGES-20250404-171048.yaml @@ -0,0 +1,5 @@ +kind: BREAKING CHANGES +body: 'importstate: renamed `ImportStateWithId` to `ImportStateWithID` and renamed `ImportCommandWithId` to `ImportCommandWithID`.' +time: 2025-04-04T17:10:48.525611-04:00 +custom: + Issue: "465" diff --git a/.changes/unreleased/BUG FIXES-20250404-170734.yaml b/.changes/unreleased/BUG FIXES-20250404-170734.yaml new file mode 100644 index 000000000..875128b40 --- /dev/null +++ b/.changes/unreleased/BUG FIXES-20250404-170734.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'importstate: plannable import (`ImportBlockWith*`) fixed for a resource with a dependency' +time: 2025-04-04T17:07:34.428542-04:00 +custom: + Issue: "476" diff --git a/helper/resource/importstate/examplecloud_test.go b/helper/resource/importstate/examplecloud_test.go index c7086d6c3..93f7b8391 100644 --- a/helper/resource/importstate/examplecloud_test.go +++ b/helper/resource/importstate/examplecloud_test.go @@ -4,6 +4,8 @@ package importstate_test import ( + "context" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" @@ -29,11 +31,7 @@ func examplecloudDataSource() testprovider.DataSource { Schema: &tfprotov6.Schema{ Block: &tfprotov6.SchemaBlock{ Attributes: []*tfprotov6.SchemaAttribute{ - { - Name: "id", - Type: tftypes.String, - Computed: true, - }, + ComputedStringAttribute("id"), }, }, }, @@ -95,21 +93,95 @@ func examplecloudResource() testprovider.Resource { Schema: &tfprotov6.Schema{ Block: &tfprotov6.SchemaBlock{ Attributes: []*tfprotov6.SchemaAttribute{ - { - Name: "id", - Type: tftypes.String, - Computed: true, - }, - { - Name: "location", - Type: tftypes.String, - Required: true, - }, - { - Name: "name", - Type: tftypes.String, - Required: true, - }, + ComputedStringAttribute("id"), + RequiredStringAttribute("location"), + RequiredStringAttribute("name"), + }, + }, + }, + }, + } +} + +// examplecloudZone is a test resource that mimics a DNS zone resource. +func examplecloudZone() testprovider.Resource { + value := tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "5381dd14-6d75-4f32-9096-47f5500b1507"), + "name": tftypes.NewValue(tftypes.String, "example.net"), + }, + ) + + return testprovider.Resource{ + CreateResponse: &resource.CreateResponse{ + NewState: value, + }, + ReadResponse: &resource.ReadResponse{ + NewState: value, + }, + ImportStateResponse: &resource.ImportStateResponse{ + State: value, + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + ComputedStringAttribute("id"), + RequiredStringAttribute("name"), + }, + }, + }, + }, + } +} + +// examplecloudZoneRecord is a test resource that mimics a DNS zone record resource. +// It models a resource dependency; specifically, it depends on a DNS zone ID and will +// plan a replacement if the zone ID changes. +func examplecloudZoneRecord() testprovider.Resource { + value := tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "zone_id": tftypes.String, + "name": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "f00911be-e188-433d-9ccd-d0393a9f5d05"), + "zone_id": tftypes.NewValue(tftypes.String, "5381dd14-6d75-4f32-9096-47f5500b1507"), + "name": tftypes.NewValue(tftypes.String, "www"), + }, + ) + + return testprovider.Resource{ + CreateResponse: &resource.CreateResponse{ + NewState: value, + }, + PlanChangeFunc: func(ctx context.Context, req resource.PlanChangeRequest, resp *resource.PlanChangeResponse) { + resp.RequiresReplace = []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("zone_id"), + } + }, + ReadResponse: &resource.ReadResponse{ + NewState: value, + }, + ImportStateResponse: &resource.ImportStateResponse{ + State: value, + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + ComputedStringAttribute("id"), + RequiredStringAttribute("zone_id"), + RequiredStringAttribute("name"), }, }, }, diff --git a/helper/resource/importstate/import_block_as_first_step_test.go b/helper/resource/importstate/import_block_as_first_step_test.go index 919659ef1..0b8cf26ee 100644 --- a/helper/resource/importstate/import_block_as_first_step_test.go +++ b/helper/resource/importstate/import_block_as_first_step_test.go @@ -18,7 +18,7 @@ import ( r "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) -func Test_ImportBlock_AsFirstStep(t *testing.T) { +func TestImportBlock_AsFirstStep(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -38,12 +38,10 @@ func Test_ImportBlock_AsFirstStep(t *testing.T) { ImportStateId: "examplecloud_container.test", ImportState: true, ImportStateKind: r.ImportBlockWithID, - // ImportStateVerify: true, Config: `resource "examplecloud_container" "test" { name = "somevalue" location = "westeurope" }`, - ImportStatePersist: true, ImportPlanChecks: r.ImportPlanChecks{ PreApply: []plancheck.PlanCheck{ plancheck.ExpectResourceAction("examplecloud_container.test", plancheck.ResourceActionNoop), diff --git a/helper/resource/importstate/import_block_for_resource_with_a_dependency_test.go b/helper/resource/importstate/import_block_for_resource_with_a_dependency_test.go new file mode 100644 index 000000000..2464929dd --- /dev/null +++ b/helper/resource/importstate/import_block_for_resource_with_a_dependency_test.go @@ -0,0 +1,52 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package importstate_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" +) + +func TestImportBlockForResourceWithADependency(t *testing.T) { + t.Parallel() + + config := ` +resource "examplecloud_zone" "zone" { + name = "example.net" +} + +resource "examplecloud_zone_record" "record" { + zone_id = examplecloud_zone.zone.id + name = "www" +} +` + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_zone": examplecloudZone(), + "examplecloud_zone_record": examplecloudZoneRecord(), + }, + }), + }, + Steps: []r.TestStep{ + { + Config: config, + }, + { + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ResourceName: "examplecloud_zone_record.record", + }, + }, + }) +} diff --git a/helper/resource/importstate/import_block_in_config_directory_test.go b/helper/resource/importstate/import_block_in_config_directory_test.go index 2cb9ab6e1..df6feecba 100644 --- a/helper/resource/importstate/import_block_in_config_directory_test.go +++ b/helper/resource/importstate/import_block_in_config_directory_test.go @@ -16,7 +16,7 @@ import ( r "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) -func Test_ImportBlock_InConfigDirectory(t *testing.T) { +func TestImportBlock_InConfigDirectory(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -35,11 +35,10 @@ func Test_ImportBlock_InConfigDirectory(t *testing.T) { ConfigDirectory: config.StaticDirectory(`testdata/1`), }, { - ResourceName: "examplecloud_container.test", - ImportState: true, - ImportStateKind: r.ImportBlockWithID, - ImportStateVerify: true, - ConfigDirectory: config.StaticDirectory(`testdata/2`), + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ConfigDirectory: config.StaticDirectory(`testdata/2`), }, }, }) diff --git a/helper/resource/importstate/import_block_in_config_file_test.go b/helper/resource/importstate/import_block_in_config_file_test.go index 8b983e310..13ef62e19 100644 --- a/helper/resource/importstate/import_block_in_config_file_test.go +++ b/helper/resource/importstate/import_block_in_config_file_test.go @@ -16,7 +16,7 @@ import ( r "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) -func Test_ImportBlock_InConfigFile(t *testing.T) { +func TestImportBlock_InConfigFile(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -35,11 +35,10 @@ func Test_ImportBlock_InConfigFile(t *testing.T) { ConfigFile: config.StaticFile(`testdata/1/examplecloud_container.tf`), }, { - ResourceName: "examplecloud_container.test", - ImportState: true, - ImportStateKind: r.ImportBlockWithID, - ImportStateVerify: true, - ConfigFile: config.StaticFile(`testdata/2/examplecloud_container_import.tf`), + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ConfigFile: config.StaticFile(`testdata/2/examplecloud_container_import.tf`), }, }, }) diff --git a/helper/resource/importstate/import_block_with_id_test.go b/helper/resource/importstate/import_block_with_id_test.go index 0f65af713..150c84d35 100644 --- a/helper/resource/importstate/import_block_with_id_test.go +++ b/helper/resource/importstate/import_block_with_id_test.go @@ -21,7 +21,7 @@ import ( r "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) -func Test_TestStep_ImportBlockId(t *testing.T) { +func TestImportBlock_WithID(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -44,16 +44,15 @@ func Test_TestStep_ImportBlockId(t *testing.T) { }`, }, { - ResourceName: "examplecloud_container.test", - ImportState: true, - ImportStateKind: r.ImportBlockWithID, - ImportStateVerify: true, + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, }, }, }) } -func TestTest_TestStep_ImportBlockId_ExpectError(t *testing.T) { +func TestImportBlock_WithID_ExpectError(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -81,17 +80,16 @@ func TestTest_TestStep_ImportBlockId_ExpectError(t *testing.T) { location = "eastus" name = "somevalue" }`, - ResourceName: "examplecloud_container.test", - ImportState: true, - ImportStateKind: r.ImportBlockWithID, - ImportStateVerify: true, - ExpectError: regexp.MustCompile(`importing resource examplecloud_container.test: expected a no-op resource action, got "update" action with plan(.?)`), + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ExpectError: regexp.MustCompile(`importing resource examplecloud_container.test: expected a no-op import operation, got.*\["update"\] action with plan(.?)`), }, }, }) } -func TestTest_TestStep_ImportBlockId_FailWhenPlannableImportIsNotSupported(t *testing.T) { +func TestImportBlock_WithID_FailWhenNotSupported(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -130,7 +128,7 @@ func TestTest_TestStep_ImportBlockId_FailWhenPlannableImportIsNotSupported(t *te }) } -func TestTest_TestStep_ImportBlockId_SkipDataSourceState(t *testing.T) { +func TestImportBlock_WithID_SkipsDataSources(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -174,9 +172,7 @@ func TestTest_TestStep_ImportBlockId_SkipDataSourceState(t *testing.T) { }) } -// These tests currently pass but only because the `getState` function which is used on the imported resource -// to do the state comparison doesn't return an error if there is no state in the working directory -func TestTest_TestStep_ImportBlockId_ImportStateVerifyIgnore_Real_Example(t *testing.T) { +func TestImportBlock_WithID_WithBlankOptionalAttribute_GeneratesCorrectPlan(t *testing.T) { /* This test tries to imitate a real world example of behaviour we often see in the AzureRM provider which requires the use of `ImportStateVerifyIgnore` when testing the import of a resource using the import command. @@ -290,17 +286,15 @@ func TestTest_TestStep_ImportBlockId_ImportStateVerifyIgnore_Real_Example(t *tes resource "examplecloud_container" "test" { name = "somename" }`, - ResourceName: "examplecloud_container.test", - ImportState: true, - ImportStateKind: r.ImportBlockWithID, - ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"password"}, + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, }, }, }) } -func TestTest_TestStep_ImportBlockId_ImportStateVerifyIgnore(t *testing.T) { +func TestImportBlock_WithID_WithBlankComputedAttribute_GeneratesCorrectPlan(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -375,11 +369,9 @@ func TestTest_TestStep_ImportBlockId_ImportStateVerifyIgnore(t *testing.T) { Config: `resource "examplecloud_container" "test" {}`, }, { - ResourceName: "examplecloud_container.test", - ImportState: true, - ImportStateKind: r.ImportBlockWithID, - ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"password"}, + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, }, }, }) diff --git a/helper/resource/importstate/import_command_as_first_step_test.go b/helper/resource/importstate/import_command_as_first_step_test.go index 771ea8dc4..14db18828 100644 --- a/helper/resource/importstate/import_command_as_first_step_test.go +++ b/helper/resource/importstate/import_command_as_first_step_test.go @@ -17,7 +17,7 @@ import ( r "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) -func Test_ImportCommand_AsFirstStep(t *testing.T) { +func TestImportCommand_AsFirstStep(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ diff --git a/helper/resource/importstate/import_command_with_id_test.go b/helper/resource/importstate/import_command_with_id_test.go index df6e43460..34e211b67 100644 --- a/helper/resource/importstate/import_command_with_id_test.go +++ b/helper/resource/importstate/import_command_with_id_test.go @@ -20,7 +20,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/tfversion" ) -func Test_TestStep_ImportStateCheck_SkipDataSourceState(t *testing.T) { +func TestImportCommand_ImportStateCheckSkipsDataSources(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -123,7 +123,7 @@ func Test_TestStep_ImportStateCheck_SkipDataSourceState(t *testing.T) { }) } -func Test_TestStep_ImportStateVerify(t *testing.T) { +func TestImportCommand_ImportStateVerify(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -197,7 +197,7 @@ func Test_TestStep_ImportStateVerify(t *testing.T) { }) } -func Test_TestStep_ImportStateVerifyIgnore(t *testing.T) { +func TestImportCommand_ImportStateVerify_Ignore(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -281,7 +281,7 @@ func Test_TestStep_ImportStateVerifyIgnore(t *testing.T) { }) } -func Test_TestStep_ExpectError_ImportState(t *testing.T) { +func TestImportCommand_ExpectError(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ diff --git a/helper/resource/importstate/types_test.go b/helper/resource/importstate/types_test.go new file mode 100644 index 000000000..8c491d30e --- /dev/null +++ b/helper/resource/importstate/types_test.go @@ -0,0 +1,33 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package importstate_test + +import ( + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func ComputedStringAttribute(name string) *tfprotov6.SchemaAttribute { + return &tfprotov6.SchemaAttribute{ + Name: name, + Type: tftypes.String, + Computed: true, + } +} + +func OptionalStringAttribute(name string) *tfprotov6.SchemaAttribute { + return &tfprotov6.SchemaAttribute{ + Name: name, + Type: tftypes.String, + Optional: true, + } +} + +func RequiredStringAttribute(name string) *tfprotov6.SchemaAttribute { + return &tfprotov6.SchemaAttribute{ + Name: name, + Type: tftypes.String, + Required: true, + } +} diff --git a/helper/resource/testing_new_import_state.go b/helper/resource/testing_new_import_state.go index 3f74838d3..dc3b06ed2 100644 --- a/helper/resource/testing_new_import_state.go +++ b/helper/resource/testing_new_import_state.go @@ -9,8 +9,6 @@ import ( "reflect" "strings" - "github.com/hashicorp/go-version" - tfjson "github.com/hashicorp/terraform-json" "github.com/google/go-cmp/cmp" @@ -30,13 +28,10 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest // step.ImportStateKind implicitly defaults to the zero-value (ImportCommandWithID) for backward compatibility kind := step.ImportStateKind - if kind.plannable() { - // Instead of calling [t.Fatal], return an error. This package's unit tests can use [TestStep.ExpectError] to match on the error message. - // An alternative, [plugintest.TestExpectTFatal], does not have access to logged error messages, so it is open to false positives on this - // complex code path. - if err := requirePlannableImport(t, *helper.TerraformVersion()); err != nil { - return err - } + importStatePersist := step.ImportStatePersist + + if err := importStatePreconditions(t, helper, step); err != nil { + return err } configRequest := teststep.PrepareConfigurationRequest{ @@ -145,11 +140,11 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest var importWd *plugintest.WorkingDir // Use the same working directory to persist the state from import - if step.ImportStatePersist { + if importStatePersist { importWd = wd } else { importWd = helper.RequireNewWorkingDir(ctx, t, "") - defer importWd.Close() //nolint:errcheck + defer importWd.Close() } err = importWd.SetConfig(ctx, testStepConfig, step.ConfigVariables) @@ -157,9 +152,24 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest t.Fatalf("Error setting test config: %s", err) } - if !step.ImportStatePersist { + if kind.plannable() { + if stepNumber > 1 { + err = importWd.CopyState(ctx, wd.StateFilePath()) + if err != nil { + t.Fatalf("copying state: %s", err) + } + + err = runProviderCommand(ctx, t, func() error { + return importWd.RemoveResource(ctx, resourceName) + }, importWd, providers) + if err != nil { + t.Fatalf("removing resource %s from copied state: %s", resourceName, err) + } + } + } + + if !importStatePersist { err = runProviderCommand(ctx, t, func() error { - logging.HelperResourceDebug(ctx, "Run terraform init") return importWd.Init(ctx) }, importWd, providers) if err != nil { @@ -172,7 +182,6 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest var opts []tfexec.PlanOption err = runProviderCommand(ctx, t, func() error { - logging.HelperResourceDebug(ctx, "Run terraform plan") return importWd.CreatePlan(ctx, opts...) }, importWd, providers) if err != nil { @@ -181,7 +190,6 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest err = runProviderCommand(ctx, t, func() error { var err error - logging.HelperResourceDebug(ctx, "Run terraform show") plan, err = importWd.SavedPlan(ctx) return err }, importWd, providers) @@ -189,36 +197,36 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest return err } - if plan.ResourceChanges != nil { - logging.HelperResourceDebug(ctx, fmt.Sprintf("ImportBlockWithId: %d resource changes", len(plan.ResourceChanges))) + logging.HelperResourceDebug(ctx, fmt.Sprintf("ImportBlockWithId: %d resource changes", len(plan.ResourceChanges))) - for _, rc := range plan.ResourceChanges { - if rc.Address != resourceName { - // we're only interested in the changes for the resource being imported - continue - } - if rc.Change != nil && rc.Change.Actions != nil { - // should this be length checked and used as a condition, if it's a no-op then there shouldn't be any other changes here - for _, action := range rc.Change.Actions { - if action != "no-op" { - var stdout string - err = runProviderCommand(ctx, t, func() error { - var err error - stdout, err = importWd.SavedPlanRawStdout(ctx) - return err - }, importWd, providers) - if err != nil { - return fmt.Errorf("retrieving formatted plan output: %w", err) - } - - return fmt.Errorf("importing resource %s: expected a no-op resource action, got %q action with plan \nstdout:\n\n%s", rc.Address, action, stdout) - } - } - } + // Verify reasonable things about the plan + var resourceChangeUnderTest *tfjson.ResourceChange + + if len(plan.ResourceChanges) == 0 { + return fmt.Errorf("importing resource %s: expected a resource change, got no changes", resourceName) + } + + for _, change := range plan.ResourceChanges { + if change.Address == resourceName { + resourceChangeUnderTest = change } } - // TODO compare plan to state from previous step + if resourceChangeUnderTest == nil || resourceChangeUnderTest.Change == nil || resourceChangeUnderTest.Change.Actions == nil { + return fmt.Errorf("importing resource %s: expected a resource change, got no changes", resourceName) + } + + change := resourceChangeUnderTest.Change + actions := change.Actions + importing := change.Importing + + switch { + case importing == nil: + return fmt.Errorf("importing resource %s: expected an import operation, got %q action with plan \nstdout:\n\n%s", resourceChangeUnderTest.Address, actions, savedPlanRawStdout(ctx, t, importWd, providers)) + + case !actions.NoOp(): + return fmt.Errorf("importing resource %s: expected a no-op import operation, got %q action with plan \nstdout:\n\n%s", resourceChangeUnderTest.Address, actions, savedPlanRawStdout(ctx, t, importWd, providers)) + } if err := runPlanChecks(ctx, t, plan, step.ImportPlanChecks.PreApply); err != nil { return err @@ -402,15 +410,28 @@ func appendImportBlock(config string, resourceName string, importID string) stri resourceName, importID) } -func requirePlannableImport(t testing.T, versionUnderTest version.Version) error { +func importStatePreconditions(t testing.T, helper *plugintest.Helper, step TestStep) error { t.Helper() - if versionUnderTest.LessThan(tfversion.Version1_5_0) { + kind := step.ImportStateKind + versionUnderTest := *helper.TerraformVersion() + + // Instead of calling [t.Fatal], we return an error. This package's unit tests can use [TestStep.ExpectError] to match + // on the error message. An alternative, [plugintest.TestExpectTFatal], does not have access to logged error messages, + // so it is open to false positives on this complex code path. + switch { + case kind.plannable() && versionUnderTest.LessThan(tfversion.Version1_5_0): return fmt.Errorf( `ImportState steps using plannable import blocks require Terraform 1.5.0 or later. Either ` + `upgrade the Terraform version running the test or add a ` + "`TerraformVersionChecks`" + ` to ` + `the test case to skip this test.` + "\n\n" + `https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/tfversion-checks#skip-version-checks`) + + case kind.plannable() && step.ImportStatePersist: + return fmt.Errorf(`ImportStatePersist is not supported with plannable import blocks`) + + case kind.plannable() && step.ImportStateVerify: + return fmt.Errorf(`ImportStateVerify is not supported with plannable import blocks`) } return nil @@ -442,3 +463,20 @@ func runImportStateCheckFunction(ctx context.Context, t testing.T, importState * logging.HelperResourceTrace(ctx, "Called TestStep ImportStateCheck") } + +func savedPlanRawStdout(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, providers *providerFactories) string { + t.Helper() + + var stdout string + + err := runProviderCommand(ctx, t, func() error { + var err error + stdout, err = wd.SavedPlanRawStdout(ctx) + return err + }, wd, providers) + + if err != nil { + return fmt.Sprintf("error retrieving formatted plan output: %s", err) + } + return stdout +} diff --git a/internal/plugintest/working_dir.go b/internal/plugintest/working_dir.go index 0cff33408..d29425c32 100644 --- a/internal/plugintest/working_dir.go +++ b/internal/plugintest/working_dir.go @@ -6,6 +6,7 @@ package plugintest import ( "context" "fmt" + "io" "os" "path/filepath" @@ -173,6 +174,40 @@ func (wd *WorkingDir) ClearState(ctx context.Context) error { return nil } +func (wd *WorkingDir) CopyState(ctx context.Context, src string) error { + srcState, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open statefile for read: %w", err) + } + + defer srcState.Close() + + dstState, err := os.Create(filepath.Join(wd.baseDir, "terraform.tfstate")) + if err != nil { + return fmt.Errorf("failed to open statefile for write: %w", err) + } + + defer dstState.Close() + + buf := make([]byte, 1024) + for { + n, err := srcState.Read(buf) + if err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("failed to read from statefile: %w", err) + } + + _, err = dstState.Write(buf[:n]) + if err != nil { + return fmt.Errorf("failed to write to statefile: %w", err) + } + } + + return nil +} + // ClearPlan deletes any saved plan present in the working directory. func (wd *WorkingDir) ClearPlan(ctx context.Context) error { logging.HelperResourceTrace(ctx, "Clearing Terraform plan") @@ -295,6 +330,17 @@ func (wd *WorkingDir) HasSavedPlan() bool { return err == nil } +// RemoveResource removes a resource from state. +func (wd *WorkingDir) RemoveResource(ctx context.Context, address string) error { + logging.HelperResourceTrace(ctx, "Calling Terraform CLI state rm command") + + err := wd.tf.StateRm(context.Background(), address) + + logging.HelperResourceTrace(ctx, "Called Terraform CLI state rm command") + + return err +} + // SavedPlan returns an object describing the current saved plan file, if any. // // If no plan is saved or if the plan file cannot be read, SavedPlan returns @@ -349,6 +395,10 @@ func (wd *WorkingDir) State(ctx context.Context) (*tfjson.State, error) { return state, err } +func (wd *WorkingDir) StateFilePath() string { + return filepath.Join(wd.baseDir, "terraform.tfstate") +} + // Import runs terraform import func (wd *WorkingDir) Import(ctx context.Context, resource, id string) error { logging.HelperResourceTrace(ctx, "Calling Terraform CLI import command")