diff --git a/go.mod b/go.mod index 4a000168..91a8ec56 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,8 @@ go 1.23.7 require ( github.com/hashicorp/go-cty v1.5.0 github.com/hashicorp/go-memdb v1.3.5 - github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/terraform-json v0.24.1-0.20250314103308-f86d5e36f4ab - github.com/hashicorp/terraform-plugin-framework v1.15.0-beta.1 + github.com/hashicorp/terraform-plugin-framework v1.15.0-beta.1.0.20250512133431-c61b1b9a48d4 github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 github.com/hashicorp/terraform-plugin-framework-nettypes v0.2.0 github.com/hashicorp/terraform-plugin-framework-timeouts v0.5.0 @@ -37,6 +36,7 @@ require ( github.com/hashicorp/go-plugin v1.6.3 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/hc-install v0.9.2 // indirect github.com/hashicorp/hcl/v2 v2.23.0 // indirect diff --git a/go.sum b/go.sum index c1542e00..321ebcc1 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,8 @@ github.com/hashicorp/terraform-exec v0.23.0 h1:MUiBM1s0CNlRFsCLJuM5wXZrzA3MnPYEs github.com/hashicorp/terraform-exec v0.23.0/go.mod h1:mA+qnx1R8eePycfwKkCRk3Wy65mwInvlpAeOwmA7vlY= github.com/hashicorp/terraform-json v0.24.1-0.20250314103308-f86d5e36f4ab h1:5Qpuprk76zkVEdTCtfoPjUc+1AeUxlgkF6sWTr7qLDs= github.com/hashicorp/terraform-json v0.24.1-0.20250314103308-f86d5e36f4ab/go.mod h1:sMKS8fiRDX4rVlR6EJUMudg1WcanxCMoWwTLkgZP/vc= -github.com/hashicorp/terraform-plugin-framework v1.15.0-beta.1 h1:lX4qacaJc8dqUzEaOALeUW0Gvv0ACs9myvN1WQ4rRgU= -github.com/hashicorp/terraform-plugin-framework v1.15.0-beta.1/go.mod h1:SNnBQzWTh3ydNHBJF8eLVHlm/2gu+RBG508LCfCSVwI= +github.com/hashicorp/terraform-plugin-framework v1.15.0-beta.1.0.20250512133431-c61b1b9a48d4 h1:bQtTmUaSW3UUd/UqB6i5YvGGKY/uh1NxfiLPmpp12gY= +github.com/hashicorp/terraform-plugin-framework v1.15.0-beta.1.0.20250512133431-c61b1b9a48d4/go.mod h1:ZlVO3Sv2z0tnLP+f3/30hOFwaiM/BNye0H8HFRN1nDo= github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 h1:SJXL5FfJJm17554Kpt9jFXngdM6fXbnUnZ6iT2IeiYA= github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0/go.mod h1:p0phD0IYhsu9bR4+6OetVvvH59I6LwjXGnTVEr8ox6E= github.com/hashicorp/terraform-plugin-framework-nettypes v0.2.0 h1:Zap24rkky7SvNGGNYHMKFhAriP6+6riI21BMYOYgLRE= diff --git a/internal/framework5provider/identity_resource.go b/internal/framework5provider/identity_resource.go index 67f0ffa3..cf5aadd6 100644 --- a/internal/framework5provider/identity_resource.go +++ b/internal/framework5provider/identity_resource.go @@ -7,14 +7,18 @@ import ( "context" "fmt" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" ) var _ resource.Resource = IdentityResource{} var _ resource.ResourceWithIdentity = IdentityResource{} +var _ resource.ResourceWithImportState = IdentityResource{} func NewIdentityResource() resource.Resource { return &IdentityResource{} @@ -42,6 +46,12 @@ func (r IdentityResource) Metadata(_ context.Context, req resource.MetadataReque func (r IdentityResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, "name": schema.StringAttribute{ Required: true, }, @@ -49,6 +59,10 @@ func (r IdentityResource) Schema(_ context.Context, _ resource.SchemaRequest, re } } +func (r IdentityResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughWithIdentity(ctx, path.Root("id"), path.Root("id"), req, resp) +} + func (r IdentityResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data IdentityResourceModel @@ -59,8 +73,10 @@ func (r IdentityResource) Create(ctx context.Context, req resource.CreateRequest resp.Diagnostics.Append(resp.Identity.Set(ctx, IdentityResourceIdentityModel{ ID: types.StringValue("id-123"), - Name: types.StringValue(fmt.Sprintf("my name is %s", data.Name.ValueString())), + Name: types.StringValue(data.Name.ValueString()), })...) + + data.ID = types.StringValue("id-123") resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -68,11 +84,25 @@ func (r IdentityResource) Read(ctx context.Context, req resource.ReadRequest, re var data IdentityResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { return } + if data.ID.ValueString() != "id-123" { + resp.Diagnostics.AddAttributeError( + path.Root("id"), + "Unexpected ID value", + fmt.Sprintf("Expected ID to be \"id-123\", got: %s", data.ID.String()), + ) + return + } + + data.Name = types.StringValue("tom") + resp.Diagnostics.Append(resp.Identity.Set(ctx, IdentityResourceIdentityModel{ + ID: types.StringValue("id-123"), + Name: types.StringValue("tom"), + })...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -92,6 +122,7 @@ func (r IdentityResource) Delete(ctx context.Context, req resource.DeleteRequest } type IdentityResourceModel struct { + ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` } diff --git a/internal/framework5provider/identity_resource_test.go b/internal/framework5provider/identity_resource_test.go index 1cf33f79..a27b9c8b 100644 --- a/internal/framework5provider/identity_resource_test.go +++ b/internal/framework5provider/identity_resource_test.go @@ -4,22 +4,22 @@ package framework import ( + "regexp" "testing" - "github.com/hashicorp/go-version" "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/knownvalue" - "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" "github.com/hashicorp/terraform-plugin-testing/tfversion" ) func TestIdentityResource(t *testing.T) { resource.UnitTest(t, resource.TestCase{ TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(version.Must(version.NewVersion("1.12.0-beta1"))), + tfversion.SkipBelow(tfversion.Version1_12_0), }, ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ "framework": providerserver.NewProtocol5WithError(New()), @@ -27,30 +27,55 @@ func TestIdentityResource(t *testing.T) { Steps: []resource.TestStep{ { Config: `resource "framework_identity" "test" { - name = "john" + name = "tom" }`, ConfigStateChecks: []statecheck.StateCheck{ statecheck.ExpectIdentity("framework_identity.test", map[string]knownvalue.Check{ "id": knownvalue.StringExact("id-123"), - "name": knownvalue.StringExact("my name is john"), + "name": knownvalue.StringExact("tom"), }), + statecheck.ExpectKnownValue("framework_identity.test", tfjsonpath.New("id"), knownvalue.StringExact("id-123")), + statecheck.ExpectKnownValue("framework_identity.test", tfjsonpath.New("name"), knownvalue.StringExact("tom")), }, }, + // Typically you don't need to test all of these different import methods, + // but this just a smoke test for passing state + identity data through. + { + ImportState: true, + ResourceName: "framework_identity.test", + ImportStateKind: resource.ImportCommandWithID, + }, + { + ImportState: true, + ResourceName: "framework_identity.test", + ImportStateKind: resource.ImportBlockWithID, + }, + { + ImportState: true, + ResourceName: "framework_identity.test", + ImportStateKind: resource.ImportBlockWithResourceIdentity, + }, + }, + }) +} + +func TestIdentityResource_identity_changes(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "framework": providerserver.NewProtocol5WithError(New()), + }, + Steps: []resource.TestStep{ { Config: `resource "framework_identity" "test" { name = "jerry" }`, - ConfigPlanChecks: resource.ConfigPlanChecks{ - PreApply: []plancheck.PlanCheck{ - plancheck.ExpectResourceAction("framework_identity.test", plancheck.ResourceActionUpdate), - }, - }, - ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectIdentity("framework_identity.test", map[string]knownvalue.Check{ - "id": knownvalue.StringExact("id-123"), - "name": knownvalue.StringExact("my name is john"), // doesn't get updated, since identity should not change. - }), - }, + // The resource is hardcoded to refresh with the same identity, based off the name attribute during create. + // Resources are currently not allowed to change identities at any time, so framework will return an error message + // after the post-apply refresh. + ExpectError: regexp.MustCompile(`Error: Unexpected Identity Change`), }, }, }) diff --git a/internal/framework5provider/move_state_resource.go b/internal/framework5provider/move_state_resource.go index 5d60fd67..06888e8c 100644 --- a/internal/framework5provider/move_state_resource.go +++ b/internal/framework5provider/move_state_resource.go @@ -6,15 +6,17 @@ package framework import ( "context" "fmt" - "strings" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" ) var _ resource.Resource = MoveStateResource{} +var _ resource.ResourceWithIdentity = MoveStateResource{} var _ resource.ResourceWithMoveState = MoveStateResource{} func NewMoveStateResource() resource.Resource { @@ -39,6 +41,16 @@ func (r MoveStateResource) Schema(_ context.Context, _ resource.SchemaRequest, r } } +func (r MoveStateResource) IdentitySchema(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } +} + func (r MoveStateResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data MoveStateResourceModel @@ -87,10 +99,71 @@ func (r MoveStateResource) MoveState(ctx context.Context) []resource.StateMover }, }, StateMover: func(ctx context.Context, req resource.MoveStateRequest, resp *resource.MoveStateResponse) { - if !strings.HasSuffix(req.SourceProviderAddress, "hashicorp/random") || req.SourceTypeName != "random_string" { + switch req.SourceProviderAddress { + case "registry.terraform.io/hashicorp/random": // Random provider (testing state moves) + if req.SourceTypeName != "random_string" { + resp.Diagnostics.AddError( + "Invalid Move State Request", + fmt.Sprintf("The \"framework_move_state\" resource can only be sourced from the \"random_string\" or \"framework_identity\" managed resources:\n\n"+ + "req.SourceProviderAddress: %q\n"+ + "req.SourceTypeName: %q\n", + req.SourceProviderAddress, + req.SourceTypeName, + ), + ) + return + } + + var oldState RandomStringResourceModel + resp.Diagnostics.Append(req.SourceState.Get(ctx, &oldState)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.TargetState.SetAttribute(ctx, path.Root("moved_random_string"), oldState.Result)...) + case "registry.terraform.io/hashicorp/framework": // Corner provider (testing identity moves) + if req.SourceTypeName != "framework_identity" { + resp.Diagnostics.AddError( + "Invalid Move State Request", + fmt.Sprintf("The \"framework_move_state\" resource can only be sourced from the \"random_string\" or \"framework_identity\" managed resources:\n\n"+ + "req.SourceProviderAddress: %q\n"+ + "req.SourceTypeName: %q\n", + req.SourceProviderAddress, + req.SourceTypeName, + ), + ) + return + } + + oldIdentityVal, err := req.SourceIdentity.Unmarshal( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + }, + }, + ) + if err != nil { + resp.Diagnostics.AddError( + "Unexpected Move State Error", + fmt.Sprintf("Error decoding source identity: %s", err.Error()), + ) + return + } + + var sourceIdentityObj map[string]tftypes.Value + var sourceID, sourceName string + + oldIdentityVal.As(&sourceIdentityObj) //nolint:errcheck // This is just a quick test of grabbing raw identity data + sourceIdentityObj["id"].As(&sourceID) //nolint:errcheck // This is just a quick test of grabbing raw identity data + sourceIdentityObj["name"].As(&sourceName) //nolint:errcheck // This is just a quick test of grabbing raw identity data + + resp.Diagnostics.Append(resp.TargetState.SetAttribute(ctx, path.Root("moved_random_string"), sourceName)...) + resp.Diagnostics.Append(resp.TargetIdentity.SetAttribute(ctx, path.Root("id"), sourceID)...) + default: resp.Diagnostics.AddError( "Invalid Move State Request", - fmt.Sprintf("This test can only migrate resource state from the \"random_string\" managed resource from the \"hashicorp/random\" provider:\n\n"+ + fmt.Sprintf("This test can only migrate resource state from hardcoded provider/resource types:\n\n"+ "req.SourceProviderAddress: %q\n"+ "req.SourceTypeName: %q\n", req.SourceProviderAddress, @@ -98,14 +171,6 @@ func (r MoveStateResource) MoveState(ctx context.Context) []resource.StateMover ), ) } - - var oldState RandomStringResourceModel - resp.Diagnostics.Append(req.SourceState.Get(ctx, &oldState)...) - if resp.Diagnostics.HasError() { - return - } - - resp.Diagnostics.Append(resp.TargetState.SetAttribute(ctx, path.Root("moved_random_string"), oldState.Result)...) }, }, } diff --git a/internal/framework5provider/move_state_resource_test.go b/internal/framework5provider/move_state_resource_test.go index 4258f935..4abc856f 100644 --- a/internal/framework5provider/move_state_resource_test.go +++ b/internal/framework5provider/move_state_resource_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-testing/compare" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" @@ -66,3 +67,48 @@ func TestMoveStateResource(t *testing.T) { }, }) } + +func TestMoveStateResource_identity(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV5ProviderFactories: map[string]func() (tfprotov5.ProviderServer, error){ + "framework": providerserver.NewProtocol5WithError(New()), + }, + Steps: []resource.TestStep{ + { + Config: `resource "framework_identity" "old" { + name = "tom" + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentity("framework_identity.old", map[string]knownvalue.Check{ + "id": knownvalue.StringExact("id-123"), + "name": knownvalue.StringExact("tom"), + }), + }, + }, + { + Config: ` + moved { + from = framework_identity.old + to = framework_move_state.new + } + resource "framework_move_state" "new" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + // The previous framework_identity.old identity should be moved to this new location, split into the new location identity and state. + statecheck.ExpectIdentity("framework_move_state.new", map[string]knownvalue.Check{ + "id": knownvalue.StringExact("id-123"), + }), + statecheck.ExpectKnownValue("framework_move_state.new", tfjsonpath.New("moved_random_string"), knownvalue.StringExact("tom")), + }, + }, + }, + }) +} diff --git a/internal/framework6provider/identity_resource.go b/internal/framework6provider/identity_resource.go index 67f0ffa3..cf5aadd6 100644 --- a/internal/framework6provider/identity_resource.go +++ b/internal/framework6provider/identity_resource.go @@ -7,14 +7,18 @@ import ( "context" "fmt" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" ) var _ resource.Resource = IdentityResource{} var _ resource.ResourceWithIdentity = IdentityResource{} +var _ resource.ResourceWithImportState = IdentityResource{} func NewIdentityResource() resource.Resource { return &IdentityResource{} @@ -42,6 +46,12 @@ func (r IdentityResource) Metadata(_ context.Context, req resource.MetadataReque func (r IdentityResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, "name": schema.StringAttribute{ Required: true, }, @@ -49,6 +59,10 @@ func (r IdentityResource) Schema(_ context.Context, _ resource.SchemaRequest, re } } +func (r IdentityResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughWithIdentity(ctx, path.Root("id"), path.Root("id"), req, resp) +} + func (r IdentityResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data IdentityResourceModel @@ -59,8 +73,10 @@ func (r IdentityResource) Create(ctx context.Context, req resource.CreateRequest resp.Diagnostics.Append(resp.Identity.Set(ctx, IdentityResourceIdentityModel{ ID: types.StringValue("id-123"), - Name: types.StringValue(fmt.Sprintf("my name is %s", data.Name.ValueString())), + Name: types.StringValue(data.Name.ValueString()), })...) + + data.ID = types.StringValue("id-123") resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -68,11 +84,25 @@ func (r IdentityResource) Read(ctx context.Context, req resource.ReadRequest, re var data IdentityResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { return } + if data.ID.ValueString() != "id-123" { + resp.Diagnostics.AddAttributeError( + path.Root("id"), + "Unexpected ID value", + fmt.Sprintf("Expected ID to be \"id-123\", got: %s", data.ID.String()), + ) + return + } + + data.Name = types.StringValue("tom") + resp.Diagnostics.Append(resp.Identity.Set(ctx, IdentityResourceIdentityModel{ + ID: types.StringValue("id-123"), + Name: types.StringValue("tom"), + })...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -92,6 +122,7 @@ func (r IdentityResource) Delete(ctx context.Context, req resource.DeleteRequest } type IdentityResourceModel struct { + ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` } diff --git a/internal/framework6provider/identity_resource_test.go b/internal/framework6provider/identity_resource_test.go index a9fdbc5d..e20ae5b0 100644 --- a/internal/framework6provider/identity_resource_test.go +++ b/internal/framework6provider/identity_resource_test.go @@ -4,22 +4,22 @@ package framework import ( + "regexp" "testing" - "github.com/hashicorp/go-version" "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/knownvalue" - "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" "github.com/hashicorp/terraform-plugin-testing/tfversion" ) func TestIdentityResource(t *testing.T) { resource.UnitTest(t, resource.TestCase{ TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(version.Must(version.NewVersion("1.12.0-beta1"))), + tfversion.SkipBelow(tfversion.Version1_12_0), }, ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ "framework": providerserver.NewProtocol6WithError(New()), @@ -27,30 +27,55 @@ func TestIdentityResource(t *testing.T) { Steps: []resource.TestStep{ { Config: `resource "framework_identity" "test" { - name = "john" + name = "tom" }`, ConfigStateChecks: []statecheck.StateCheck{ statecheck.ExpectIdentity("framework_identity.test", map[string]knownvalue.Check{ "id": knownvalue.StringExact("id-123"), - "name": knownvalue.StringExact("my name is john"), + "name": knownvalue.StringExact("tom"), }), + statecheck.ExpectKnownValue("framework_identity.test", tfjsonpath.New("id"), knownvalue.StringExact("id-123")), + statecheck.ExpectKnownValue("framework_identity.test", tfjsonpath.New("name"), knownvalue.StringExact("tom")), }, }, + // Typically you don't need to test all of these different import methods, + // but this just a smoke test for passing state + identity data through. + { + ImportState: true, + ResourceName: "framework_identity.test", + ImportStateKind: resource.ImportCommandWithID, + }, + { + ImportState: true, + ResourceName: "framework_identity.test", + ImportStateKind: resource.ImportBlockWithID, + }, + { + ImportState: true, + ResourceName: "framework_identity.test", + ImportStateKind: resource.ImportBlockWithResourceIdentity, + }, + }, + }) +} + +func TestIdentityResource_identity_changes(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "framework": providerserver.NewProtocol6WithError(New()), + }, + Steps: []resource.TestStep{ { Config: `resource "framework_identity" "test" { name = "jerry" }`, - ConfigPlanChecks: resource.ConfigPlanChecks{ - PreApply: []plancheck.PlanCheck{ - plancheck.ExpectResourceAction("framework_identity.test", plancheck.ResourceActionUpdate), - }, - }, - ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectIdentity("framework_identity.test", map[string]knownvalue.Check{ - "id": knownvalue.StringExact("id-123"), - "name": knownvalue.StringExact("my name is john"), // doesn't get updated, since identity should not change. - }), - }, + // The resource is hardcoded to refresh with the same identity, based off the name attribute during create. + // Resources are currently not allowed to change identities at any time, so framework will return an error message + // after the post-apply refresh. + ExpectError: regexp.MustCompile(`Error: Unexpected Identity Change`), }, }, }) diff --git a/internal/framework6provider/move_state_resource.go b/internal/framework6provider/move_state_resource.go index 5d60fd67..d1ed0043 100644 --- a/internal/framework6provider/move_state_resource.go +++ b/internal/framework6provider/move_state_resource.go @@ -6,15 +6,17 @@ package framework import ( "context" "fmt" - "strings" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" ) var _ resource.Resource = MoveStateResource{} +var _ resource.ResourceWithIdentity = MoveStateResource{} var _ resource.ResourceWithMoveState = MoveStateResource{} func NewMoveStateResource() resource.Resource { @@ -39,6 +41,16 @@ func (r MoveStateResource) Schema(_ context.Context, _ resource.SchemaRequest, r } } +func (r MoveStateResource) IdentitySchema(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } +} + func (r MoveStateResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data MoveStateResourceModel @@ -87,10 +99,71 @@ func (r MoveStateResource) MoveState(ctx context.Context) []resource.StateMover }, }, StateMover: func(ctx context.Context, req resource.MoveStateRequest, resp *resource.MoveStateResponse) { - if !strings.HasSuffix(req.SourceProviderAddress, "hashicorp/random") || req.SourceTypeName != "random_string" { + switch req.SourceProviderAddress { + case "registry.terraform.io/hashicorp/random": // Random provider (testing state moves) + if req.SourceTypeName != "random_string" { + resp.Diagnostics.AddError( + "Invalid Move State Request", + fmt.Sprintf("This test can only migrate resource state from the \"random_string\" managed resource from the \"hashicorp/random\" provider:\n\n"+ + "req.SourceProviderAddress: %q\n"+ + "req.SourceTypeName: %q\n", + req.SourceProviderAddress, + req.SourceTypeName, + ), + ) + return + } + + var oldState RandomStringResourceModel + resp.Diagnostics.Append(req.SourceState.Get(ctx, &oldState)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.TargetState.SetAttribute(ctx, path.Root("moved_random_string"), oldState.Result)...) + case "registry.terraform.io/hashicorp/framework": // Corner provider (testing identity moves) + if req.SourceTypeName != "framework_identity" { + resp.Diagnostics.AddError( + "Invalid Move State Request", + fmt.Sprintf("This test can only migrate resource state from the \"framework_identity\" managed resource from the \"hashicorp/framework\" provider:\n\n"+ + "req.SourceProviderAddress: %q\n"+ + "req.SourceTypeName: %q\n", + req.SourceProviderAddress, + req.SourceTypeName, + ), + ) + return + } + + oldIdentityVal, err := req.SourceIdentity.Unmarshal( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "name": tftypes.String, + }, + }, + ) + if err != nil { + resp.Diagnostics.AddError( + "Unexpected Move State Error", + fmt.Sprintf("Error decoding source identity: %s", err.Error()), + ) + return + } + + var sourceIdentityObj map[string]tftypes.Value + var sourceID, sourceName string + + oldIdentityVal.As(&sourceIdentityObj) //nolint:errcheck // This is just a quick test of grabbing raw identity data + sourceIdentityObj["id"].As(&sourceID) //nolint:errcheck // This is just a quick test of grabbing raw identity data + sourceIdentityObj["name"].As(&sourceName) //nolint:errcheck // This is just a quick test of grabbing raw identity data + + resp.Diagnostics.Append(resp.TargetState.SetAttribute(ctx, path.Root("moved_random_string"), sourceName)...) + resp.Diagnostics.Append(resp.TargetIdentity.SetAttribute(ctx, path.Root("id"), sourceID)...) + default: resp.Diagnostics.AddError( "Invalid Move State Request", - fmt.Sprintf("This test can only migrate resource state from the \"random_string\" managed resource from the \"hashicorp/random\" provider:\n\n"+ + fmt.Sprintf("This test can only migrate resource state from hardcoded provider/resource types:\n\n"+ "req.SourceProviderAddress: %q\n"+ "req.SourceTypeName: %q\n", req.SourceProviderAddress, @@ -98,14 +171,6 @@ func (r MoveStateResource) MoveState(ctx context.Context) []resource.StateMover ), ) } - - var oldState RandomStringResourceModel - resp.Diagnostics.Append(req.SourceState.Get(ctx, &oldState)...) - if resp.Diagnostics.HasError() { - return - } - - resp.Diagnostics.Append(resp.TargetState.SetAttribute(ctx, path.Root("moved_random_string"), oldState.Result)...) }, }, } diff --git a/internal/framework6provider/move_state_resource_test.go b/internal/framework6provider/move_state_resource_test.go index 3404f12e..d834b9de 100644 --- a/internal/framework6provider/move_state_resource_test.go +++ b/internal/framework6provider/move_state_resource_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-testing/compare" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" "github.com/hashicorp/terraform-plugin-testing/plancheck" "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" @@ -66,3 +67,48 @@ func TestMoveStateResource(t *testing.T) { }, }) } + +func TestMoveStateResource_identity(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "framework": providerserver.NewProtocol6WithError(New()), + }, + Steps: []resource.TestStep{ + { + Config: `resource "framework_identity" "old" { + name = "tom" + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentity("framework_identity.old", map[string]knownvalue.Check{ + "id": knownvalue.StringExact("id-123"), + "name": knownvalue.StringExact("tom"), + }), + }, + }, + { + Config: ` + moved { + from = framework_identity.old + to = framework_move_state.new + } + resource "framework_move_state" "new" {} + `, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + ConfigStateChecks: []statecheck.StateCheck{ + // The previous framework_identity.old identity should be moved to this new location, split into the new location identity and state. + statecheck.ExpectIdentity("framework_move_state.new", map[string]knownvalue.Check{ + "id": knownvalue.StringExact("id-123"), + }), + statecheck.ExpectKnownValue("framework_move_state.new", tfjsonpath.New("moved_random_string"), knownvalue.StringExact("tom")), + }, + }, + }, + }) +}