diff --git a/.changes/unreleased/NOTES-20250414-095537.yaml b/.changes/unreleased/NOTES-20250414-095537.yaml new file mode 100644 index 000000000..b87031a6e --- /dev/null +++ b/.changes/unreleased/NOTES-20250414-095537.yaml @@ -0,0 +1,5 @@ +kind: NOTES +body: 'This beta pre-release adds support for managed resource identity, which can be used with Terraform v1.12.0-beta2. Acceptance tests can use the `ImportBlockWithResourceIdentity` kind to exercise the import of a managed resource using its resource identity object values instead of using a string identifier.' +time: 2025-04-14T09:55:37.938453-04:00 +custom: + Issue: "480" diff --git a/helper/resource/importstate/examplecloud_test.go b/helper/resource/importstate/examplecloud_test.go index 93f7b8391..5e7ab4179 100644 --- a/helper/resource/importstate/examplecloud_test.go +++ b/helper/resource/importstate/examplecloud_test.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/datasource" "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/teststep" ) func examplecloudDataSource() testprovider.DataSource { @@ -56,6 +57,16 @@ func examplecloudResource() testprovider.Resource { "name": tftypes.NewValue(tftypes.String, "somevalue"), }, ), + NewIdentity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), + }, + )), }, ReadResponse: &resource.ReadResponse{ NewState: tftypes.NewValue( @@ -72,6 +83,16 @@ func examplecloudResource() testprovider.Resource { "name": tftypes.NewValue(tftypes.String, "somevalue"), }, ), + NewIdentity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), + }, + )), }, ImportStateResponse: &resource.ImportStateResponse{ State: tftypes.NewValue( @@ -88,6 +109,16 @@ func examplecloudResource() testprovider.Resource { "name": tftypes.NewValue(tftypes.String, "somevalue"), }, ), + Identity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "westeurope/somevalue"), + }, + )), }, SchemaResponse: &resource.SchemaResponse{ Schema: &tfprotov6.Schema{ @@ -100,6 +131,18 @@ func examplecloudResource() testprovider.Resource { }, }, }, + IdentitySchemaResponse: &resource.IdentitySchemaResponse{ + Schema: &tfprotov6.ResourceIdentitySchema{ + Version: 1, + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + RequiredForImport: true, + }, + }, + }, + }, } } @@ -188,3 +231,251 @@ func examplecloudZoneRecord() testprovider.Resource { }, } } + +func examplecloudResourceWithEveryIdentitySchemaType() testprovider.Resource { + return testprovider.Resource{ + CreateResponse: &resource.CreateResponse{ + NewState: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "hostname": tftypes.String, + "cabinet": tftypes.String, + "unit": tftypes.Number, + "active": tftypes.Bool, + "tags": tftypes.List{ElementType: tftypes.String}, + "magic_numbers": tftypes.List{ElementType: tftypes.Number}, + "beep_boop": tftypes.List{ElementType: tftypes.Bool}, + }, + }, + map[string]tftypes.Value{ + "hostname": tftypes.NewValue(tftypes.String, "mail.example.net"), + "cabinet": tftypes.NewValue(tftypes.String, "A1"), + "unit": tftypes.NewValue(tftypes.Number, 14), + "active": tftypes.NewValue(tftypes.Bool, true), + "tags": tftypes.NewValue( + tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "storage"), + tftypes.NewValue(tftypes.String, "fast")}), + "magic_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 5), + tftypes.NewValue(tftypes.Number, 2)}), + "beep_boop": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Bool}, []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, false), + tftypes.NewValue(tftypes.Bool, true), + }), + }, + ), + NewIdentity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "cabinet": tftypes.String, + "unit": tftypes.Number, + "active": tftypes.Bool, + "tags": tftypes.List{ElementType: tftypes.String}, + "magic_numbers": tftypes.List{ElementType: tftypes.Number}, + "beep_boop": tftypes.List{ElementType: tftypes.Bool}, + }, + }, + map[string]tftypes.Value{ + "cabinet": tftypes.NewValue(tftypes.String, "A1"), + "unit": tftypes.NewValue(tftypes.Number, 14), + "active": tftypes.NewValue(tftypes.Bool, true), + "tags": tftypes.NewValue( + tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "storage"), + tftypes.NewValue(tftypes.String, "fast"), + }), + "magic_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 5), + tftypes.NewValue(tftypes.Number, 2)}), + "beep_boop": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Bool}, []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, false), + tftypes.NewValue(tftypes.Bool, true), + }), + }, + )), + }, + ReadResponse: &resource.ReadResponse{ + NewState: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "hostname": tftypes.String, + "cabinet": tftypes.String, + "unit": tftypes.Number, + "active": tftypes.Bool, + "tags": tftypes.List{ElementType: tftypes.String}, + "magic_numbers": tftypes.List{ElementType: tftypes.Number}, + "beep_boop": tftypes.List{ElementType: tftypes.Bool}, + }, + }, + map[string]tftypes.Value{ + "hostname": tftypes.NewValue(tftypes.String, "mail.example.net"), + "cabinet": tftypes.NewValue(tftypes.String, "A1"), + "unit": tftypes.NewValue(tftypes.Number, 14), + "active": tftypes.NewValue(tftypes.Bool, true), + "tags": tftypes.NewValue( + tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "storage"), + tftypes.NewValue(tftypes.String, "fast")}), + "magic_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 5), + tftypes.NewValue(tftypes.Number, 2)}), + "beep_boop": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Bool}, []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, false), + tftypes.NewValue(tftypes.Bool, true), + }), + }, + ), + NewIdentity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "cabinet": tftypes.String, + "unit": tftypes.Number, + "active": tftypes.Bool, + "tags": tftypes.List{ElementType: tftypes.String}, + "magic_numbers": tftypes.List{ElementType: tftypes.Number}, + "beep_boop": tftypes.List{ElementType: tftypes.Bool}, + }, + }, + map[string]tftypes.Value{ + "cabinet": tftypes.NewValue(tftypes.String, "A1"), + "unit": tftypes.NewValue(tftypes.Number, 14), + "active": tftypes.NewValue(tftypes.Bool, true), + "tags": tftypes.NewValue( + tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "storage"), + tftypes.NewValue(tftypes.String, "fast")}), + "magic_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 5), + tftypes.NewValue(tftypes.Number, 2)}), + "beep_boop": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Bool}, []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, false), + tftypes.NewValue(tftypes.Bool, true), + }), + }, + )), + }, + ImportStateResponse: &resource.ImportStateResponse{ + State: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "hostname": tftypes.String, + "cabinet": tftypes.String, + "unit": tftypes.Number, + "active": tftypes.Bool, + "tags": tftypes.List{ElementType: tftypes.String}, + "magic_numbers": tftypes.List{ElementType: tftypes.Number}, + "beep_boop": tftypes.List{ElementType: tftypes.Bool}, + }, + }, + map[string]tftypes.Value{ + "hostname": tftypes.NewValue(tftypes.String, "mail.example.net"), + "cabinet": tftypes.NewValue(tftypes.String, "A1"), + "unit": tftypes.NewValue(tftypes.Number, 14), + "active": tftypes.NewValue(tftypes.Bool, true), + "tags": tftypes.NewValue( + tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "storage"), + tftypes.NewValue(tftypes.String, "fast")}), + "magic_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 5), + tftypes.NewValue(tftypes.Number, 2)}), + "beep_boop": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Bool}, []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, false), + tftypes.NewValue(tftypes.Bool, true), + }), + }, + ), + Identity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "cabinet": tftypes.String, + "unit": tftypes.Number, + "active": tftypes.Bool, + "tags": tftypes.List{ElementType: tftypes.String}, + "magic_numbers": tftypes.List{ElementType: tftypes.Number}, + "beep_boop": tftypes.List{ElementType: tftypes.Bool}, + }, + }, + map[string]tftypes.Value{ + "cabinet": tftypes.NewValue(tftypes.String, "A1"), + "unit": tftypes.NewValue(tftypes.Number, 14), + "active": tftypes.NewValue(tftypes.Bool, true), + "tags": tftypes.NewValue( + tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "storage"), + tftypes.NewValue(tftypes.String, "fast")}), + "magic_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 5), + tftypes.NewValue(tftypes.Number, 2)}), + "beep_boop": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Bool}, []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, false), + tftypes.NewValue(tftypes.Bool, true), + }), + }, + )), + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + ComputedStringAttribute("hostname"), + RequiredStringAttribute("cabinet"), + RequiredNumberAttribute("unit"), + RequiredBoolAttribute("active"), + RequiredListAttribute("tags", tftypes.String), + OptionalComputedListAttribute("magic_numbers", tftypes.Number), + }, + }, + }, + }, + IdentitySchemaResponse: &resource.IdentitySchemaResponse{ + Schema: &tfprotov6.ResourceIdentitySchema{ + Version: 1, + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "cabinet", + Type: tftypes.String, + RequiredForImport: true, + }, + { + Name: "unit", + Type: tftypes.Number, + OptionalForImport: true, + }, + { + Name: "active", + Type: tftypes.Bool, + OptionalForImport: true, + }, + { + Name: "tags", + Type: tftypes.List{ + ElementType: tftypes.String, + }, + OptionalForImport: true, + }, + { + Name: "magic_numbers", + Type: tftypes.List{ + ElementType: tftypes.Number, + }, + OptionalForImport: true, + }, + }, + }, + }, + } +} 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 0b8cf26ee..915c61b3c 100644 --- a/helper/resource/importstate/import_block_as_first_step_test.go +++ b/helper/resource/importstate/import_block_as_first_step_test.go @@ -41,7 +41,13 @@ func TestImportBlock_AsFirstStep(t *testing.T) { Config: `resource "examplecloud_container" "test" { name = "somevalue" location = "westeurope" - }`, + } + + import { + to = examplecloud_container.test + id = "westeurope/somevalue" + } + `, ImportPlanChecks: r.ImportPlanChecks{ PreApply: []plancheck.PlanCheck{ plancheck.ExpectResourceAction("examplecloud_container.test", plancheck.ResourceActionNoop), 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 13ef62e19..2d5a97a29 100644 --- a/helper/resource/importstate/import_block_in_config_file_test.go +++ b/helper/resource/importstate/import_block_in_config_file_test.go @@ -43,3 +43,31 @@ func TestImportBlock_InConfigFile(t *testing.T) { }, }) } + +func TestImportBlock_WithResourceIdentity_InConfigFile(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), // ImportBlockWithResourceIdentity requires Terraform 1.12.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { + ConfigFile: config.StaticFile(`testdata/1/examplecloud_container.tf`), + }, + { + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithResourceIdentity, + ConfigFile: config.StaticFile(`testdata/examplecloud_container_import_with_identity.tf`), + }, + }, + }) +} diff --git a/helper/resource/importstate/import_block_with_id_test.go b/helper/resource/importstate/import_block_with_id_test.go index da552fef5..ef9661503 100644 --- a/helper/resource/importstate/import_block_with_id_test.go +++ b/helper/resource/importstate/import_block_with_id_test.go @@ -82,7 +82,13 @@ func TestImportBlock_WithID_ExpectError(t *testing.T) { resource "examplecloud_container" "test" { location = "eastus" name = "somevalue" - }`, + } + + import { + to = examplecloud_container.test + id = "westeurope/somevalue" + } + `, ResourceName: "examplecloud_container.test", ImportState: true, ImportStateKind: r.ImportBlockWithID, @@ -116,16 +122,10 @@ func TestImportBlock_WithID_FailWhenNotSupported(t *testing.T) { }`, }, { - Config: ` - resource "examplecloud_container" "test" { - location = "eastus" - name = "somevalue" - }`, - ResourceName: "examplecloud_container.test", - ImportState: true, - ImportStateKind: r.ImportBlockWithID, - ImportStateVerify: true, - ExpectError: regexp.MustCompile(`Terraform 1.5.0`), + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ResourceName: "examplecloud_container.test", + ExpectError: regexp.MustCompile(`Terraform 1.5.0`), }, }, }) @@ -288,6 +288,12 @@ func TestImportBlock_WithID_WithBlankOptionalAttribute_GeneratesCorrectPlan(t *t resource "examplecloud_container" "test" { name = "somename" + } + + import { + to = examplecloud_container.test + id = "sometestid" + }`, ResourceName: "examplecloud_container.test", ImportState: true, diff --git a/helper/resource/importstate/import_block_with_resource_identity_test.go b/helper/resource/importstate/import_block_with_resource_identity_test.go new file mode 100644 index 000000000..77e8a3cd7 --- /dev/null +++ b/helper/resource/importstate/import_block_with_resource_identity_test.go @@ -0,0 +1,114 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package importstate_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "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" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestImportBlock_WithResourceIdentity(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), // ImportBlockWithResourceIdentity requires Terraform 1.12.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { + Config: ` + resource "examplecloud_container" "test" { + location = "westeurope" + name = "somevalue" + }`, + }, + { + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithResourceIdentity, + }, + }, + }) +} + +func TestImportBlock_WithResourceIdentity_WithEveryType(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), // ImportBlockWithResourceIdentity requires Terraform 1.12.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResourceWithEveryIdentitySchemaType(), + }, + }), + }, + Steps: []r.TestStep{ + { + Config: ` + resource "examplecloud_container" "test" { + cabinet = "A1" + unit = 14 + tags = ["storage", "fast"] + active = true + }`, + }, + { + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithResourceIdentity, + }, + }, + }) +} + +func TestImportBlock_WithResourceIdentity_RequiresVersion1_12_0(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories + tfversion.SkipAbove(tfversion.Version1_11_0), // ImportBlockWithResourceIdentity requires Terraform 1.12.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { + Config: ` + resource "examplecloud_container" "test" { + location = "westeurope" + name = "somevalue" + }`, + }, + { + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithResourceIdentity, + ExpectError: regexp.MustCompile(`Terraform 1.12.0\S* or later`), + }, + }, + }) +} diff --git a/helper/resource/importstate/testdata/examplecloud_container_import_with_identity.tf b/helper/resource/importstate/testdata/examplecloud_container_import_with_identity.tf new file mode 100644 index 000000000..9412afb97 --- /dev/null +++ b/helper/resource/importstate/testdata/examplecloud_container_import_with_identity.tf @@ -0,0 +1,14 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +resource "examplecloud_container" "test" { + name = "somevalue" + location = "westeurope" +} + +import { + to = examplecloud_container.test + identity = { + id = "examplecloud_container.test" + } +} diff --git a/helper/resource/importstate/types_test.go b/helper/resource/importstate/types_test.go index 8c491d30e..8532b40da 100644 --- a/helper/resource/importstate/types_test.go +++ b/helper/resource/importstate/types_test.go @@ -8,6 +8,39 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" ) +func RequiredBoolAttribute(name string) *tfprotov6.SchemaAttribute { + return &tfprotov6.SchemaAttribute{ + Name: name, + Type: tftypes.Bool, + Required: true, + } +} + +func OptionalComputedListAttribute(name string, elementType tftypes.Type) *tfprotov6.SchemaAttribute { + return &tfprotov6.SchemaAttribute{ + Name: name, + Type: tftypes.List{ElementType: elementType}, + Optional: true, + Computed: true, + } +} + +func RequiredListAttribute(name string, elementType tftypes.Type) *tfprotov6.SchemaAttribute { + return &tfprotov6.SchemaAttribute{ + Name: name, + Type: tftypes.List{ElementType: elementType}, + Required: true, + } +} + +func RequiredNumberAttribute(name string) *tfprotov6.SchemaAttribute { + return &tfprotov6.SchemaAttribute{ + Name: name, + Type: tftypes.Number, + Required: true, + } +} + func ComputedStringAttribute(name string) *tfprotov6.SchemaAttribute { return &tfprotov6.SchemaAttribute{ Name: name, diff --git a/helper/resource/plugin.go b/helper/resource/plugin.go index 9fba439b7..843faab09 100644 --- a/helper/resource/plugin.go +++ b/helper/resource/plugin.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/go-hclog" "github.com/hashicorp/terraform-exec/tfexec" + tfjson "github.com/hashicorp/terraform-json" "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -113,6 +114,58 @@ type providerFactories struct { protov6 protov6ProviderFactories } +func runProviderCommandApply(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, factories *providerFactories) error { + t.Helper() + + fn := func() error { + return wd.Apply(ctx) + } + return runProviderCommand(ctx, t, fn, wd, factories) +} + +func runProviderCommandCreatePlan(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, factories *providerFactories) error { + t.Helper() + + fn := func() error { + return wd.CreatePlan(ctx) + } + return runProviderCommand(ctx, t, fn, wd, factories) +} + +func runProviderCommandGetStateJSON(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, factories *providerFactories) (*tfjson.State, error) { + t.Helper() + + var stateJSON *tfjson.State + fn := func() error { + var err error + stateJSON, err = wd.State(ctx) + return err + } + err := runProviderCommand(ctx, t, fn, wd, factories) + if err != nil { + return nil, err + } + + return stateJSON, nil +} + +func runProviderCommandSavedPlan(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, factories *providerFactories) (*tfjson.Plan, error) { + t.Helper() + + var plan *tfjson.Plan + fn := func() error { + var err error + plan, err = wd.SavedPlan(ctx) + return err + } + err := runProviderCommand(ctx, t, fn, wd, factories) + if err != nil { + return nil, err + } + + return plan, nil +} + func runProviderCommand(ctx context.Context, t testing.T, f func() error, wd *plugintest.WorkingDir, factories *providerFactories) error { // don't point to this as a test failure location // point to whatever called it diff --git a/helper/resource/testing.go b/helper/resource/testing.go index cda787033..0cfba4991 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -467,10 +467,16 @@ const ( ImportBlockWithResourceIdentity ) +// plannable reports whether this kind indicates the use of plannable import blocks func (kind ImportStateKind) plannable() bool { return kind == ImportBlockWithID || kind == ImportBlockWithResourceIdentity } +// resourceIdentity reports whether this kind indicates the use of resource identity in import blocks +func (kind ImportStateKind) resourceIdentity() bool { + return kind == ImportBlockWithResourceIdentity +} + func (kind ImportStateKind) String() string { return map[ImportStateKind]string{ ImportCommandWithID: "ImportCommandWithID", diff --git a/helper/resource/testing_new_import_state.go b/helper/resource/testing_new_import_state.go index 4d315fab9..b20bf736c 100644 --- a/helper/resource/testing_new_import_state.go +++ b/helper/resource/testing_new_import_state.go @@ -5,16 +5,18 @@ package resource import ( "context" + "encoding/json" "fmt" "reflect" "strings" + "github.com/hashicorp/go-version" + tfjson "github.com/hashicorp/terraform-json" "github.com/google/go-cmp/cmp" "github.com/mitchellh/go-testing-interface" - "github.com/hashicorp/terraform-exec/tfexec" "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/internal/logging" "github.com/hashicorp/terraform-plugin-testing/internal/plugintest" @@ -23,7 +25,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/tfversion" ) -func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest.Helper, wd *plugintest.WorkingDir, step TestStep, cfgRaw string, providers *providerFactories, stepNumber int) error { +func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest.Helper, testCaseWorkingDir *plugintest.WorkingDir, step TestStep, cfgRaw string, providers *providerFactories, stepNumber int) error { t.Helper() // step.ImportStateKind implicitly defaults to the zero-value (ImportCommandWithID) for backward compatibility @@ -57,19 +59,16 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest var err error err = runProviderCommand(ctx, t, func() error { - stateJSON, state, err = getState(ctx, t, wd) + stateJSON, state, err = getState(ctx, t, testCaseWorkingDir) if err != nil { return err } return nil - }, wd, providers) + }, testCaseWorkingDir, providers) if err != nil { t.Fatalf("Error getting state: %s", err) } - // TODO: this statement is a placeholder -- it simply prevents stateJSON from being unused - logging.HelperResourceTrace(ctx, fmt.Sprintf("State before import: values %v", stateJSON.Values != nil)) - // Determine the ID to import var importId string switch { @@ -109,12 +108,23 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest logging.HelperResourceTrace(ctx, fmt.Sprintf("Using import identifier: %s", importId)) + var priorIdentityValues map[string]any + + if kind.plannable() && kind.resourceIdentity() { + priorIdentityValues = identityValuesFromState(stateJSON, resourceName) + if len(priorIdentityValues) == 0 { + return fmt.Errorf("importing resource %s: expected prior state to have resource identity values, got none", resourceName) + } + } + // Append to previous step config unless using explicit inline Config, or ConfigFile, or ConfigDirectory if testStepConfig == nil && step.ConfigFile == nil && step.ConfigDirectory == nil { logging.HelperResourceTrace(ctx, "Using prior TestStep Config for import") importConfig := cfgRaw - if kind.plannable() { + if kind.plannable() && kind.resourceIdentity() { + importConfig = appendImportBlockWithIdentity(importConfig, resourceName, priorIdentityValues) + } else if kind.plannable() { importConfig = appendImportBlock(importConfig, resourceName, importId) } @@ -134,31 +144,31 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest } } - var importWd *plugintest.WorkingDir + var workingDir *plugintest.WorkingDir // Use the same working directory to persist the state from import if importStatePersist { - importWd = wd + workingDir = testCaseWorkingDir } else { - importWd = helper.RequireNewWorkingDir(ctx, t, "") - defer importWd.Close() + workingDir = helper.RequireNewWorkingDir(ctx, t, "") + defer workingDir.Close() } - err = importWd.SetConfig(ctx, testStepConfig, step.ConfigVariables) + err = workingDir.SetConfig(ctx, testStepConfig, step.ConfigVariables) if err != nil { t.Fatalf("Error setting test config: %s", err) } if kind.plannable() { if stepNumber > 1 { - err = importWd.CopyState(ctx, wd.StateFilePath()) + err = workingDir.CopyState(ctx, testCaseWorkingDir.StateFilePath()) if err != nil { t.Fatalf("copying state: %s", err) } err = runProviderCommand(ctx, t, func() error { - return importWd.RemoveResource(ctx, resourceName) - }, importWd, providers) + return workingDir.RemoveResource(ctx, resourceName) + }, workingDir, providers) if err != nil { t.Fatalf("removing resource %s from copied state: %s", resourceName, err) } @@ -167,8 +177,8 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest if !importStatePersist { err = runProviderCommand(ctx, t, func() error { - return importWd.Init(ctx) - }, importWd, providers) + return workingDir.Init(ctx) + }, workingDir, providers) if err != nil { t.Fatalf("Error running init: %s", err) } @@ -176,22 +186,16 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest var plan *tfjson.Plan if kind.plannable() { - var opts []tfexec.PlanOption + // TODO: extract to a function -- this is a long `if` :) - err = runProviderCommand(ctx, t, func() error { - return importWd.CreatePlan(ctx, opts...) - }, importWd, providers) + err := runProviderCommandCreatePlan(ctx, t, workingDir, providers) if err != nil { - return err + return fmt.Errorf("generating plan with import config: %s", err) } - err = runProviderCommand(ctx, t, func() error { - var err error - plan, err = importWd.SavedPlan(ctx) - return err - }, importWd, providers) + plan, err = runProviderCommandSavedPlan(ctx, t, workingDir, providers) if err != nil { - return err + return fmt.Errorf("reading generated plan with import config: %s", err) } logging.HelperResourceDebug(ctx, fmt.Sprintf("ImportBlockWithId: %d resource changes", len(plan.ResourceChanges))) @@ -219,32 +223,56 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest 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)) + 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, workingDir, 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)) + 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, workingDir, providers)) } if err := runPlanChecks(ctx, t, plan, step.ImportPlanChecks.PreApply); err != nil { return err } - } else { - err = runProviderCommand(ctx, t, func() error { - return importWd.Import(ctx, resourceName, importId) - }, importWd, providers) - if err != nil { - return err + + { + if kind.resourceIdentity() { + err := runProviderCommandApply(ctx, t, workingDir, providers) + if err != nil { + return fmt.Errorf("applying plan with import config: %s", err) + } + + newStateJSON, err := runProviderCommandGetStateJSON(ctx, t, workingDir, providers) + if err != nil { + return fmt.Errorf("getting state after applying plan with import config: %s", err) + } + + newIdentityValues := identityValuesFromState(newStateJSON, resourceName) + if !cmp.Equal(priorIdentityValues, newIdentityValues) { + return fmt.Errorf("importing resource %s: expected identity values %v, got %v", resourceName, priorIdentityValues, newIdentityValues) + } + } } + + return nil } + // TODO: extract to a function -- this is an implicit `else` for the long `if` above :) + var importState *terraform.State + err = runProviderCommand(ctx, t, func() error { - _, importState, err = getState(ctx, t, importWd) + return workingDir.Import(ctx, resourceName, importId) + }, workingDir, providers) + if err != nil { + return err + } + + err = runProviderCommand(ctx, t, func() error { + _, importState, err = getState(ctx, t, workingDir) if err != nil { return err } return nil - }, importWd, providers) + }, workingDir, providers) if err != nil { t.Fatalf("Error getting state: %s", err) } @@ -407,16 +435,64 @@ func appendImportBlock(config string, resourceName string, importID string) stri resourceName, importID) } +func appendImportBlockWithIdentity(config string, resourceName string, identityValues map[string]any) string { + configBuilder := config + configBuilder += fmt.Sprintf(``+"\n"+ + `import {`+"\n"+ + ` to = %s`+"\n"+ + ` identity = {`+"\n", + resourceName) + + for k, v := range identityValues { + switch v := v.(type) { + case bool: + configBuilder += fmt.Sprintf(` %q = %t`+"\n", k, v) + + case []any: + var quotedV []string + for _, v := range v { + quotedV = append(quotedV, fmt.Sprintf(`%q`, v)) + } + configBuilder += fmt.Sprintf(` %q = [%s]`+"\n", k, strings.Join(quotedV, ", ")) + + case json.Number: + configBuilder += fmt.Sprintf(` %q = %s`+"\n", k, v) + + case string: + configBuilder += fmt.Sprintf(` %q = %q`+"\n", k, v) + + default: + panic(fmt.Sprintf("unexpected type %T for identity value %q", v, k)) + } + } + + configBuilder += `` + + ` }` + "\n" + + `}` + "\n" + + return configBuilder +} + func importStatePreconditions(t testing.T, helper *plugintest.Helper, step TestStep) error { t.Helper() kind := step.ImportStateKind - versionUnderTest := *helper.TerraformVersion() + versionUnderTest := *helper.TerraformVersion().Core() + resourceIdentityMinimumVersion := version.Must(version.NewVersion("1.12.0")) // 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. + // + // Multiple cases may match, so check the most specific cases first switch { + case kind.resourceIdentity() && versionUnderTest.LessThan(resourceIdentityMinimumVersion): + return fmt.Errorf( + `ImportState steps using resource identity require Terraform 1.12.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() && versionUnderTest.LessThan(tfversion.Version1_5_0): return fmt.Errorf( `ImportState steps using plannable import blocks require Terraform 1.5.0 or later. Either ` + @@ -434,6 +510,33 @@ func importStatePreconditions(t testing.T, helper *plugintest.Helper, step TestS return nil } +func resourcesFromState(state *tfjson.State) []*tfjson.StateResource { + stateValues := state.Values + if stateValues == nil || stateValues.RootModule == nil { + return []*tfjson.StateResource{} + } + + return stateValues.RootModule.Resources +} + +func identityValuesFromState(state *tfjson.State, resourceName string) map[string]any { + var resource *tfjson.StateResource + resources := resourcesFromState(state) + + for _, r := range resources { + if r.Address == resourceName { + resource = r + break + } + } + + if resource == nil || len(resource.IdentityValues) == 0 { + return map[string]any{} + } + + return resource.IdentityValues +} + func runImportStateCheckFunction(ctx context.Context, t testing.T, importState *terraform.State, step TestStep) { t.Helper()