Skip to content

ResourceIdentity: Add support for import by identity and update pass-through implementations #1126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changes/unreleased/NOTES-20250403-121229.yaml
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My plan was to release a framework 1.15.0-beta.1 with this PR (along with Rain's currently open #1125 and #1123 )

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
kind: NOTES
body: This beta pre-release continues the implementation of managed resource identity, which should now be used with Terraform v1.12.0-beta1.
Managed resources now can support import by identity during plan and apply workflows. Managed resources that already support import via the
`resource.ResourceWithImportState` interface will automatically pass-through identity data to the `Read` method. The `RequiredForImport` and
`OptionalForImport` fields on the identity schema can be used to control the validation that Terraform core will apply to the import config block.
time: 2025-04-03T12:12:29.323193-04:00
custom:
Issue: "1126"
9 changes: 8 additions & 1 deletion internal/fromproto5/importresourcestate.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (

// ImportResourceStateRequest returns the *fwserver.ImportResourceStateRequest
// equivalent of a *tfprotov5.ImportResourceStateRequest.
func ImportResourceStateRequest(ctx context.Context, proto5 *tfprotov5.ImportResourceStateRequest, reqResource resource.Resource, resourceSchema fwschema.Schema) (*fwserver.ImportResourceStateRequest, diag.Diagnostics) {
func ImportResourceStateRequest(ctx context.Context, proto5 *tfprotov5.ImportResourceStateRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, identitySchema fwschema.Schema) (*fwserver.ImportResourceStateRequest, diag.Diagnostics) {
if proto5 == nil {
return nil, nil
}
Expand All @@ -45,10 +45,17 @@ func ImportResourceStateRequest(ctx context.Context, proto5 *tfprotov5.ImportRes
Schema: resourceSchema,
},
ID: proto5.ID,
IdentitySchema: identitySchema,
Resource: reqResource,
TypeName: proto5.TypeName,
ClientCapabilities: ImportStateClientCapabilities(proto5.ClientCapabilities),
}

identity, identityDiags := ResourceIdentity(ctx, proto5.Identity, identitySchema)

diags.Append(identityDiags...)

fw.Identity = identity

return fw, diags
}
64 changes: 63 additions & 1 deletion internal/fromproto5/importresourcestate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/internal/fwschema"
"github.com/hashicorp/terraform-plugin-framework/internal/fwserver"
"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/tfsdk"
)
Expand All @@ -31,6 +32,30 @@ func TestImportResourceStateRequest(t *testing.T) {
},
}

testIdentityProto5Type := tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"test_identity_attribute": tftypes.String,
},
}

testIdentityProto5Value := tftypes.NewValue(testIdentityProto5Type, map[string]tftypes.Value{
"test_identity_attribute": tftypes.NewValue(tftypes.String, "id-123"),
})

testIdentityProto5DynamicValue, err := tfprotov5.NewDynamicValue(testIdentityProto5Type, testIdentityProto5Value)

if err != nil {
t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err)
}

testIdentitySchema := identityschema.Schema{
Attributes: map[string]identityschema.Attribute{
"test_identity_attribute": identityschema.StringAttribute{
RequiredForImport: true,
},
},
}

testFwEmptyState := tfsdk.State{
Raw: tftypes.NewValue(testFwSchema.Type().TerraformType(context.Background()), nil),
Schema: testFwSchema,
Expand All @@ -39,6 +64,7 @@ func TestImportResourceStateRequest(t *testing.T) {
testCases := map[string]struct {
input *tfprotov5.ImportResourceStateRequest
resourceSchema fwschema.Schema
identitySchema fwschema.Schema
resource resource.Resource
expected *fwserver.ImportResourceStateRequest
expectedDiagnostics diag.Diagnostics
Expand Down Expand Up @@ -67,6 +93,42 @@ func TestImportResourceStateRequest(t *testing.T) {
),
},
},
"identity-missing-schema": {
input: &tfprotov5.ImportResourceStateRequest{
Identity: &tfprotov5.ResourceIdentityData{
IdentityData: &testIdentityProto5DynamicValue,
},
},
resourceSchema: testFwSchema,
expected: &fwserver.ImportResourceStateRequest{
EmptyState: testFwEmptyState,
},
expectedDiagnostics: diag.Diagnostics{
diag.NewErrorDiagnostic(
"Unable to Convert Resource Identity",
"An unexpected error was encountered when converting the resource identity from the protocol type. "+
"Identity data was sent in the protocol to a resource that doesn't support identity.\n\n"+
"This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.",
),
},
},
"identity": {
input: &tfprotov5.ImportResourceStateRequest{
Identity: &tfprotov5.ResourceIdentityData{
IdentityData: &testIdentityProto5DynamicValue,
},
},
resourceSchema: testFwSchema,
identitySchema: testIdentitySchema,
expected: &fwserver.ImportResourceStateRequest{
EmptyState: testFwEmptyState,
IdentitySchema: testIdentitySchema,
Identity: &tfsdk.ResourceIdentity{
Raw: testIdentityProto5Value,
Schema: testIdentitySchema,
},
},
},
"id": {
input: &tfprotov5.ImportResourceStateRequest{
ID: "test-id",
Expand Down Expand Up @@ -122,7 +184,7 @@ func TestImportResourceStateRequest(t *testing.T) {
t.Run(name, func(t *testing.T) {
t.Parallel()

got, diags := fromproto5.ImportResourceStateRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema)
got, diags := fromproto5.ImportResourceStateRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.identitySchema)

if diff := cmp.Diff(got, testCase.expected); diff != "" {
t.Errorf("unexpected difference: %s", diff)
Expand Down
9 changes: 8 additions & 1 deletion internal/fromproto6/importresourcestate.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (

// ImportResourceStateRequest returns the *fwserver.ImportResourceStateRequest
// equivalent of a *tfprotov6.ImportResourceStateRequest.
func ImportResourceStateRequest(ctx context.Context, proto6 *tfprotov6.ImportResourceStateRequest, reqResource resource.Resource, resourceSchema fwschema.Schema) (*fwserver.ImportResourceStateRequest, diag.Diagnostics) {
func ImportResourceStateRequest(ctx context.Context, proto6 *tfprotov6.ImportResourceStateRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, identitySchema fwschema.Schema) (*fwserver.ImportResourceStateRequest, diag.Diagnostics) {
if proto6 == nil {
return nil, nil
}
Expand All @@ -45,10 +45,17 @@ func ImportResourceStateRequest(ctx context.Context, proto6 *tfprotov6.ImportRes
Schema: resourceSchema,
},
ID: proto6.ID,
IdentitySchema: identitySchema,
Resource: reqResource,
TypeName: proto6.TypeName,
ClientCapabilities: ImportStateClientCapabilities(proto6.ClientCapabilities),
}

identity, identityDiags := ResourceIdentity(ctx, proto6.Identity, identitySchema)

diags.Append(identityDiags...)

fw.Identity = identity

return fw, diags
}
64 changes: 63 additions & 1 deletion internal/fromproto6/importresourcestate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/internal/fwschema"
"github.com/hashicorp/terraform-plugin-framework/internal/fwserver"
"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/tfsdk"
)
Expand All @@ -31,6 +32,30 @@ func TestImportResourceStateRequest(t *testing.T) {
},
}

testIdentityProto6Type := tftypes.Object{
AttributeTypes: map[string]tftypes.Type{
"test_identity_attribute": tftypes.String,
},
}

testIdentityProto6Value := tftypes.NewValue(testIdentityProto6Type, map[string]tftypes.Value{
"test_identity_attribute": tftypes.NewValue(tftypes.String, "id-123"),
})

testIdentityProto6DynamicValue, err := tfprotov6.NewDynamicValue(testIdentityProto6Type, testIdentityProto6Value)

if err != nil {
t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err)
}

testIdentitySchema := identityschema.Schema{
Attributes: map[string]identityschema.Attribute{
"test_identity_attribute": identityschema.StringAttribute{
RequiredForImport: true,
},
},
}

testFwEmptyState := tfsdk.State{
Raw: tftypes.NewValue(testFwSchema.Type().TerraformType(context.Background()), nil),
Schema: testFwSchema,
Expand All @@ -39,6 +64,7 @@ func TestImportResourceStateRequest(t *testing.T) {
testCases := map[string]struct {
input *tfprotov6.ImportResourceStateRequest
resourceSchema fwschema.Schema
identitySchema fwschema.Schema
resource resource.Resource
expected *fwserver.ImportResourceStateRequest
expectedDiagnostics diag.Diagnostics
Expand Down Expand Up @@ -67,6 +93,42 @@ func TestImportResourceStateRequest(t *testing.T) {
),
},
},
"identity-missing-schema": {
input: &tfprotov6.ImportResourceStateRequest{
Identity: &tfprotov6.ResourceIdentityData{
IdentityData: &testIdentityProto6DynamicValue,
},
},
resourceSchema: testFwSchema,
expected: &fwserver.ImportResourceStateRequest{
EmptyState: testFwEmptyState,
},
expectedDiagnostics: diag.Diagnostics{
diag.NewErrorDiagnostic(
"Unable to Convert Resource Identity",
"An unexpected error was encountered when converting the resource identity from the protocol type. "+
"Identity data was sent in the protocol to a resource that doesn't support identity.\n\n"+
"This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.",
),
},
},
"identity": {
input: &tfprotov6.ImportResourceStateRequest{
Identity: &tfprotov6.ResourceIdentityData{
IdentityData: &testIdentityProto6DynamicValue,
},
},
resourceSchema: testFwSchema,
identitySchema: testIdentitySchema,
expected: &fwserver.ImportResourceStateRequest{
EmptyState: testFwEmptyState,
IdentitySchema: testIdentitySchema,
Identity: &tfsdk.ResourceIdentity{
Raw: testIdentityProto6Value,
Schema: testIdentitySchema,
},
},
},
"id": {
input: &tfprotov6.ImportResourceStateRequest{
ID: "test-id",
Expand Down Expand Up @@ -122,7 +184,7 @@ func TestImportResourceStateRequest(t *testing.T) {
t.Run(name, func(t *testing.T) {
t.Parallel()

got, diags := fromproto6.ImportResourceStateRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema)
got, diags := fromproto6.ImportResourceStateRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.identitySchema)

if diff := cmp.Diff(got, testCase.expected); diff != "" {
t.Errorf("unexpected difference: %s", diff)
Expand Down
56 changes: 54 additions & 2 deletions internal/fwserver/server_importresourcestate.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/hashicorp/terraform-plugin-go/tftypes"

"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/internal/fwschema"
"github.com/hashicorp/terraform-plugin-framework/internal/logging"
"github.com/hashicorp/terraform-plugin-framework/internal/privatestate"
"github.com/hashicorp/terraform-plugin-framework/resource"
Expand All @@ -18,14 +19,29 @@ import (
// ImportedResource represents a resource that was imported.
type ImportedResource struct {
Private *privatestate.Data
Identity *tfsdk.ResourceIdentity
State tfsdk.State
TypeName string
}

// ImportResourceStateRequest is the framework server request for the
// ImportResourceState RPC.
//
// Either ID or Identity will be supplied depending on how the resource is being imported.
type ImportResourceStateRequest struct {
ID string
// ID will come from the import CLI command or an import config block with the "id" attribute assigned.
//
// This ID field is a special string identifier that can be parsed however the provider deems fit.
ID string

// Identity will come from an import config block with the "identity" attribute assigned and will conform
// to the identity schema defined by the resource. (Terraform v1.12+)
//
// All attributes marked as RequiredForImport will be populated (enforced by Terraform core) and OptionalForImport
// attributes may be null, but could have a config value.
Identity *tfsdk.ResourceIdentity
IdentitySchema fwschema.Schema

Resource resource.Resource

// EmptyState is an empty State for the resource schema. This is used to
Expand Down Expand Up @@ -132,6 +148,29 @@ func (s *Server) ImportResourceState(ctx context.Context, req *ImportResourceSta
Private: privateProviderData,
}

// If the resource supports identity and we are not importing by identity, pre-populate with a null value.
// TODO:ResourceIdentity: Is there any reason a provider WOULD NOT want to populate an identity when it supports one?
if req.Identity == nil && req.IdentitySchema != nil {
nullTfValue := tftypes.NewValue(req.IdentitySchema.Type().TerraformType(ctx), nil)

req.Identity = &tfsdk.ResourceIdentity{
Schema: req.IdentitySchema,
Raw: nullTfValue.Copy(),
}
}

if req.Identity != nil {
importReq.Identity = &tfsdk.ResourceIdentity{
Schema: req.Identity.Schema,
Raw: req.Identity.Raw.Copy(),
}

importResp.Identity = &tfsdk.ResourceIdentity{
Schema: req.Identity.Schema,
Raw: req.Identity.Raw.Copy(),
}
}

logging.FrameworkTrace(ctx, "Calling provider defined Resource ImportState")
resourceWithImportState.ImportState(ctx, importReq, &importResp)
logging.FrameworkTrace(ctx, "Called provider defined Resource ImportState")
Expand All @@ -154,7 +193,9 @@ func (s *Server) ImportResourceState(ctx context.Context, req *ImportResourceSta

importResp.State.Raw = modifiedState

if importResp.State.Raw.Equal(req.EmptyState.Raw) {
// If we are importing by ID, we should ensure that something in the import stub state has been populated,
// otherwise the resource doesn't actually support import, which is a provider issue.
if req.ID != "" && importResp.State.Raw.Equal(req.EmptyState.Raw) {
resp.Diagnostics.AddError(
"Missing Resource Import State",
"An unexpected error was encountered when importing the resource. This is always a problem with the provider. Please give the following information to the provider developer:\n\n"+
Expand All @@ -169,10 +210,21 @@ func (s *Server) ImportResourceState(ctx context.Context, req *ImportResourceSta
private.Provider = importResp.Private
}

if importResp.Identity != nil && req.IdentitySchema == nil {
resp.Diagnostics.AddError(
"Unexpected ImportState Response",
"An unexpected error was encountered when creating the import response. New identity data was returned by the provider import operation, but the resource does not indicate identity support.\n\n"+
"This is always a problem with the provider and should be reported to the provider developer.",
)

return
}

resp.Deferred = importResp.Deferred
resp.ImportedResources = []ImportedResource{
{
State: importResp.State,
Identity: importResp.Identity,
TypeName: req.TypeName,
Private: private,
},
Expand Down
Loading
Loading