diff --git a/internal/fromproto5/applyresourcechange.go b/internal/fromproto5/applyresourcechange.go index 08d04d4a..c32b3438 100644 --- a/internal/fromproto5/applyresourcechange.go +++ b/internal/fromproto5/applyresourcechange.go @@ -17,7 +17,7 @@ import ( // ApplyResourceChangeRequest returns the *fwserver.ApplyResourceChangeRequest // equivalent of a *tfprotov5.ApplyResourceChangeRequest. -func ApplyResourceChangeRequest(ctx context.Context, proto5 *tfprotov5.ApplyResourceChangeRequest, resource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema) (*fwserver.ApplyResourceChangeRequest, diag.Diagnostics) { +func ApplyResourceChangeRequest(ctx context.Context, proto5 *tfprotov5.ApplyResourceChangeRequest, resource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema, identitySchema fwschema.Schema) (*fwserver.ApplyResourceChangeRequest, diag.Diagnostics) { if proto5 == nil { return nil, nil } @@ -40,6 +40,7 @@ func ApplyResourceChangeRequest(ctx context.Context, proto5 *tfprotov5.ApplyReso fw := &fwserver.ApplyResourceChangeRequest{ ResourceSchema: resourceSchema, + IdentitySchema: identitySchema, Resource: resource, } @@ -55,6 +56,12 @@ func ApplyResourceChangeRequest(ctx context.Context, proto5 *tfprotov5.ApplyReso fw.PlannedState = plannedState + plannedIdentity, plannedIdentityDiags := ResourceIdentity(ctx, proto5.PlannedIdentity, identitySchema) + + diags.Append(plannedIdentityDiags...) + + fw.PlannedIdentity = plannedIdentity + priorState, priorStateDiags := State(ctx, proto5.PriorState, resourceSchema) diags.Append(priorStateDiags...) diff --git a/internal/fromproto5/applyresourcechange_test.go b/internal/fromproto5/applyresourcechange_test.go index 85918136..a6b09e01 100644 --- a/internal/fromproto5/applyresourcechange_test.go +++ b/internal/fromproto5/applyresourcechange_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "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" ) @@ -48,6 +49,30 @@ func TestApplyResourceChangeRequest(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, + }, + }, + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -61,6 +86,7 @@ func TestApplyResourceChangeRequest(t *testing.T) { resourceSchema fwschema.Schema resource resource.Resource providerMetaSchema fwschema.Schema + identitySchema fwschema.Schema expected *fwserver.ApplyResourceChangeRequest expectedDiagnostics diag.Diagnostics }{ @@ -137,6 +163,42 @@ func TestApplyResourceChangeRequest(t *testing.T) { ResourceSchema: testFwSchema, }, }, + "plannedidentity-missing-schema": { + input: &tfprotov5.ApplyResourceChangeRequest{ + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testIdentityProto5DynamicValue, + }, + }, + resourceSchema: testFwSchema, + expected: &fwserver.ApplyResourceChangeRequest{ + ResourceSchema: testFwSchema, + }, + 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.", + ), + }, + }, + "plannedidentity": { + input: &tfprotov5.ApplyResourceChangeRequest{ + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testIdentityProto5DynamicValue, + }, + }, + identitySchema: testIdentitySchema, + resourceSchema: testFwSchema, + expected: &fwserver.ApplyResourceChangeRequest{ + IdentitySchema: testIdentitySchema, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: testIdentityProto5Value, + Schema: testIdentitySchema, + }, + ResourceSchema: testFwSchema, + }, + }, "plannedprivate-malformed-json": { input: &tfprotov5.ApplyResourceChangeRequest{ PlannedPrivate: []byte(`{`), @@ -253,7 +315,7 @@ func TestApplyResourceChangeRequest(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto5.ApplyResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema) + got, diags := fromproto5.ApplyResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema, testCase.identitySchema) if diff := cmp.Diff(got, testCase.expected, cmp.AllowUnexported(privatestate.ProviderData{})); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fromproto5/planresourcechange.go b/internal/fromproto5/planresourcechange.go index 5bd24c1d..2255dc35 100644 --- a/internal/fromproto5/planresourcechange.go +++ b/internal/fromproto5/planresourcechange.go @@ -17,7 +17,7 @@ import ( // PlanResourceChangeRequest returns the *fwserver.PlanResourceChangeRequest // equivalent of a *tfprotov5.PlanResourceChangeRequest. -func PlanResourceChangeRequest(ctx context.Context, proto5 *tfprotov5.PlanResourceChangeRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema, resourceBehavior resource.ResourceBehavior) (*fwserver.PlanResourceChangeRequest, diag.Diagnostics) { +func PlanResourceChangeRequest(ctx context.Context, proto5 *tfprotov5.PlanResourceChangeRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema, resourceBehavior resource.ResourceBehavior, identitySchema fwschema.Schema) (*fwserver.PlanResourceChangeRequest, diag.Diagnostics) { if proto5 == nil { return nil, nil } @@ -41,6 +41,7 @@ func PlanResourceChangeRequest(ctx context.Context, proto5 *tfprotov5.PlanResour fw := &fwserver.PlanResourceChangeRequest{ ResourceBehavior: resourceBehavior, ResourceSchema: resourceSchema, + IdentitySchema: identitySchema, Resource: reqResource, ClientCapabilities: ModifyPlanClientCapabilities(proto5.ClientCapabilities), } @@ -57,6 +58,12 @@ func PlanResourceChangeRequest(ctx context.Context, proto5 *tfprotov5.PlanResour fw.PriorState = priorState + priorIdentity, priorIdentityDiags := ResourceIdentity(ctx, proto5.PriorIdentity, identitySchema) + + diags.Append(priorIdentityDiags...) + + fw.PriorIdentity = priorIdentity + proposedNewState, proposedNewStateDiags := Plan(ctx, proto5.ProposedNewState, resourceSchema) diags.Append(proposedNewStateDiags...) diff --git a/internal/fromproto5/planresourcechange_test.go b/internal/fromproto5/planresourcechange_test.go index b223e0e5..fd37a170 100644 --- a/internal/fromproto5/planresourcechange_test.go +++ b/internal/fromproto5/planresourcechange_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "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" ) @@ -48,6 +49,30 @@ func TestPlanResourceChangeRequest(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, + }, + }, + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -58,6 +83,7 @@ func TestPlanResourceChangeRequest(t *testing.T) { input *tfprotov5.PlanResourceChangeRequest resourceBehavior resource.ResourceBehavior resourceSchema fwschema.Schema + identitySchema fwschema.Schema resource resource.Resource providerMetaSchema fwschema.Schema expected *fwserver.PlanResourceChangeRequest @@ -182,6 +208,42 @@ func TestPlanResourceChangeRequest(t *testing.T) { ResourceSchema: testFwSchema, }, }, + "prioridentity-missing-schema": { + input: &tfprotov5.PlanResourceChangeRequest{ + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testIdentityProto5DynamicValue, + }, + }, + resourceSchema: testFwSchema, + expected: &fwserver.PlanResourceChangeRequest{ + ResourceSchema: testFwSchema, + }, + 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.", + ), + }, + }, + "prioridentity": { + input: &tfprotov5.PlanResourceChangeRequest{ + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testIdentityProto5DynamicValue, + }, + }, + identitySchema: testIdentitySchema, + resourceSchema: testFwSchema, + expected: &fwserver.PlanResourceChangeRequest{ + IdentitySchema: testIdentitySchema, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: testIdentityProto5Value, + Schema: testIdentitySchema, + }, + ResourceSchema: testFwSchema, + }, + }, "providermeta-missing-data": { input: &tfprotov5.PlanResourceChangeRequest{}, resourceSchema: testFwSchema, @@ -265,7 +327,7 @@ func TestPlanResourceChangeRequest(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto5.PlanResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema, testCase.resourceBehavior) + got, diags := fromproto5.PlanResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema, testCase.resourceBehavior, testCase.identitySchema) if diff := cmp.Diff(got, testCase.expected, cmp.AllowUnexported(privatestate.ProviderData{})); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fromproto5/readresource.go b/internal/fromproto5/readresource.go index 04d4b4d2..00342457 100644 --- a/internal/fromproto5/readresource.go +++ b/internal/fromproto5/readresource.go @@ -17,7 +17,7 @@ import ( // ReadResourceRequest returns the *fwserver.ReadResourceRequest // equivalent of a *tfprotov5.ReadResourceRequest. -func ReadResourceRequest(ctx context.Context, proto5 *tfprotov5.ReadResourceRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema) (*fwserver.ReadResourceRequest, diag.Diagnostics) { +func ReadResourceRequest(ctx context.Context, proto5 *tfprotov5.ReadResourceRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema, identitySchema fwschema.Schema) (*fwserver.ReadResourceRequest, diag.Diagnostics) { if proto5 == nil { return nil, nil } @@ -26,6 +26,7 @@ func ReadResourceRequest(ctx context.Context, proto5 *tfprotov5.ReadResourceRequ fw := &fwserver.ReadResourceRequest{ Resource: reqResource, + IdentitySchema: identitySchema, ClientCapabilities: ReadResourceClientCapabilities(proto5.ClientCapabilities), } @@ -35,6 +36,12 @@ func ReadResourceRequest(ctx context.Context, proto5 *tfprotov5.ReadResourceRequ fw.CurrentState = currentState + currentIdentity, currentIdentityDiags := ResourceIdentity(ctx, proto5.CurrentIdentity, identitySchema) + + diags.Append(currentIdentityDiags...) + + fw.CurrentIdentity = currentIdentity + providerMeta, providerMetaDiags := ProviderMeta(ctx, proto5.ProviderMeta, providerMetaSchema) diags.Append(providerMetaDiags...) diff --git a/internal/fromproto5/readresource_test.go b/internal/fromproto5/readresource_test.go index c58fc01c..1c8adb7f 100644 --- a/internal/fromproto5/readresource_test.go +++ b/internal/fromproto5/readresource_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "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" ) @@ -48,6 +49,30 @@ func TestReadResourceRequest(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, + }, + }, + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -59,6 +84,7 @@ func TestReadResourceRequest(t *testing.T) { testCases := map[string]struct { input *tfprotov5.ReadResourceRequest resourceSchema fwschema.Schema + identitySchema fwschema.Schema resource resource.Resource providerMetaSchema fwschema.Schema expected *fwserver.ReadResourceRequest @@ -99,6 +125,37 @@ func TestReadResourceRequest(t *testing.T) { }, }, }, + "currentidentity-missing-schema": { + input: &tfprotov5.ReadResourceRequest{ + CurrentIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testIdentityProto5DynamicValue, + }, + }, + expected: &fwserver.ReadResourceRequest{}, + 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.", + ), + }, + }, + "currentidentity": { + input: &tfprotov5.ReadResourceRequest{ + CurrentIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testIdentityProto5DynamicValue, + }, + }, + identitySchema: testIdentitySchema, + expected: &fwserver.ReadResourceRequest{ + IdentitySchema: testIdentitySchema, + CurrentIdentity: &tfsdk.ResourceIdentity{ + Raw: testIdentityProto5Value, + Schema: testIdentitySchema, + }, + }, + }, "private-malformed-json": { input: &tfprotov5.ReadResourceRequest{ Private: []byte(`{`), @@ -200,7 +257,7 @@ func TestReadResourceRequest(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto5.ReadResourceRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema) + got, diags := fromproto5.ReadResourceRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema, testCase.identitySchema) if diff := cmp.Diff(got, testCase.expected, cmp.AllowUnexported(privatestate.ProviderData{})); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fromproto5/resource_identity.go b/internal/fromproto5/resource_identity.go new file mode 100644 index 00000000..c6e1a801 --- /dev/null +++ b/internal/fromproto5/resource_identity.go @@ -0,0 +1,62 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto5 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// TODO:ResourceIdentity: Should we create a wrapping struct to contain the identity data? To match the protocol (in-case we want to introduce other identity things) +// - Need to think more on this (like what if we want to introduce display-only attributes) +// - If we introduce one, add a test as well. +func ResourceIdentity(ctx context.Context, in *tfprotov5.ResourceIdentityData, schema fwschema.Schema) (*tfsdk.ResourceIdentity, diag.Diagnostics) { + if in == nil { + return nil, nil + } + + return IdentityData(ctx, in.IdentityData, schema) +} + +// IdentityData returns the *tfsdk.ResourceIdentity for a *tfprotov5.DynamicValue and fwschema.Schema. +func IdentityData(ctx context.Context, proto5DynamicValue *tfprotov5.DynamicValue, schema fwschema.Schema) (*tfsdk.ResourceIdentity, diag.Diagnostics) { + if proto5DynamicValue == nil { + return nil, nil + } + + var diags diag.Diagnostics + + // Panic prevention here to simplify the calling implementations. + // This should not happen, but just in case. + if schema == nil { + diags.AddError( + "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.", + ) + + return nil, diags + } + + data, dynamicValueDiags := DynamicValue(ctx, proto5DynamicValue, schema, fwschemadata.DataDescriptionResourceIdentity) + + diags.Append(dynamicValueDiags...) + + if diags.HasError() { + return nil, diags + } + + fw := &tfsdk.ResourceIdentity{ + Raw: data.TerraformValue, + Schema: schema, + } + + return fw, diags +} diff --git a/internal/fromproto5/resource_identity_test.go b/internal/fromproto5/resource_identity_test.go new file mode 100644 index 00000000..8e768714 --- /dev/null +++ b/internal/fromproto5/resource_identity_test.go @@ -0,0 +1,129 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto5_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestResourceIdentity(t *testing.T) { + t.Parallel() + + testProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute": tftypes.String, + }, + } + + testProto5Value := tftypes.NewValue(testProto5Type, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }) + + testProto5DynamicValue, err := tfprotov5.NewDynamicValue(testProto5Type, testProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testFwSchema := testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + RequiredForImport: true, + Type: types.StringType, + }, + }, + } + + testFwSchemaInvalid := testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + RequiredForImport: true, + Type: types.BoolType, + }, + }, + } + + testCases := map[string]struct { + input *tfprotov5.ResourceIdentityData + schema fwschema.Schema + expected *tfsdk.ResourceIdentity + expectedDiagnostics diag.Diagnostics + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &tfprotov5.ResourceIdentityData{}, + expected: nil, + }, + "missing-schema": { + input: &tfprotov5.ResourceIdentityData{ + IdentityData: &testProto5DynamicValue, + }, + expected: nil, + 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.", + ), + }, + }, + "invalid-schema": { + input: &tfprotov5.ResourceIdentityData{ + IdentityData: &testProto5DynamicValue, + }, + schema: testFwSchemaInvalid, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Resource Identity", + "An unexpected error was encountered when converting the resource identity from the protocol type. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Unable to unmarshal DynamicValue: AttributeName(\"test_attribute\"): couldn't decode bool: msgpack: invalid code=aa decoding bool", + ), + }, + }, + "valid": { + input: &tfprotov5.ResourceIdentityData{ + IdentityData: &testProto5DynamicValue, + }, + schema: testFwSchema, + expected: &tfsdk.ResourceIdentity{ + Raw: testProto5Value, + Schema: testFwSchema, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := fromproto5.ResourceIdentity(context.Background(), testCase.input, testCase.schema) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/internal/fromproto6/applyresourcechange.go b/internal/fromproto6/applyresourcechange.go index f48eb856..5620ffe4 100644 --- a/internal/fromproto6/applyresourcechange.go +++ b/internal/fromproto6/applyresourcechange.go @@ -17,7 +17,7 @@ import ( // ApplyResourceChangeRequest returns the *fwserver.ApplyResourceChangeRequest // equivalent of a *tfprotov6.ApplyResourceChangeRequest. -func ApplyResourceChangeRequest(ctx context.Context, proto6 *tfprotov6.ApplyResourceChangeRequest, resource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema) (*fwserver.ApplyResourceChangeRequest, diag.Diagnostics) { +func ApplyResourceChangeRequest(ctx context.Context, proto6 *tfprotov6.ApplyResourceChangeRequest, resource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema, identitySchema fwschema.Schema) (*fwserver.ApplyResourceChangeRequest, diag.Diagnostics) { if proto6 == nil { return nil, nil } @@ -40,6 +40,7 @@ func ApplyResourceChangeRequest(ctx context.Context, proto6 *tfprotov6.ApplyReso fw := &fwserver.ApplyResourceChangeRequest{ ResourceSchema: resourceSchema, + IdentitySchema: identitySchema, Resource: resource, } @@ -55,6 +56,12 @@ func ApplyResourceChangeRequest(ctx context.Context, proto6 *tfprotov6.ApplyReso fw.PlannedState = plannedState + plannedIdentity, plannedIdentityDiags := ResourceIdentity(ctx, proto6.PlannedIdentity, identitySchema) + + diags.Append(plannedIdentityDiags...) + + fw.PlannedIdentity = plannedIdentity + priorState, priorStateDiags := State(ctx, proto6.PriorState, resourceSchema) diags.Append(priorStateDiags...) diff --git a/internal/fromproto6/applyresourcechange_test.go b/internal/fromproto6/applyresourcechange_test.go index 9f845412..d29913fb 100644 --- a/internal/fromproto6/applyresourcechange_test.go +++ b/internal/fromproto6/applyresourcechange_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "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" ) @@ -48,6 +49,30 @@ func TestApplyResourceChangeRequest(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, + }, + }, + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -61,6 +86,7 @@ func TestApplyResourceChangeRequest(t *testing.T) { resourceSchema fwschema.Schema resource resource.Resource providerMetaSchema fwschema.Schema + identitySchema fwschema.Schema expected *fwserver.ApplyResourceChangeRequest expectedDiagnostics diag.Diagnostics }{ @@ -137,6 +163,42 @@ func TestApplyResourceChangeRequest(t *testing.T) { ResourceSchema: testFwSchema, }, }, + "plannedidentity-missing-schema": { + input: &tfprotov6.ApplyResourceChangeRequest{ + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testIdentityProto6DynamicValue, + }, + }, + resourceSchema: testFwSchema, + expected: &fwserver.ApplyResourceChangeRequest{ + ResourceSchema: testFwSchema, + }, + 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.", + ), + }, + }, + "plannedidentity": { + input: &tfprotov6.ApplyResourceChangeRequest{ + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testIdentityProto6DynamicValue, + }, + }, + identitySchema: testIdentitySchema, + resourceSchema: testFwSchema, + expected: &fwserver.ApplyResourceChangeRequest{ + IdentitySchema: testIdentitySchema, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: testIdentityProto6Value, + Schema: testIdentitySchema, + }, + ResourceSchema: testFwSchema, + }, + }, "plannedprivate-malformed-json": { input: &tfprotov6.ApplyResourceChangeRequest{ PlannedPrivate: []byte(`{`), @@ -253,7 +315,7 @@ func TestApplyResourceChangeRequest(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto6.ApplyResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema) + got, diags := fromproto6.ApplyResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema, testCase.identitySchema) if diff := cmp.Diff(got, testCase.expected, cmp.AllowUnexported(privatestate.ProviderData{})); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fromproto6/planresourcechange.go b/internal/fromproto6/planresourcechange.go index 6b95f078..4bffe04d 100644 --- a/internal/fromproto6/planresourcechange.go +++ b/internal/fromproto6/planresourcechange.go @@ -17,7 +17,7 @@ import ( // PlanResourceChangeRequest returns the *fwserver.PlanResourceChangeRequest // equivalent of a *tfprotov6.PlanResourceChangeRequest. -func PlanResourceChangeRequest(ctx context.Context, proto6 *tfprotov6.PlanResourceChangeRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema, resourceBehavior resource.ResourceBehavior) (*fwserver.PlanResourceChangeRequest, diag.Diagnostics) { +func PlanResourceChangeRequest(ctx context.Context, proto6 *tfprotov6.PlanResourceChangeRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema, resourceBehavior resource.ResourceBehavior, identitySchema fwschema.Schema) (*fwserver.PlanResourceChangeRequest, diag.Diagnostics) { if proto6 == nil { return nil, nil } @@ -41,6 +41,7 @@ func PlanResourceChangeRequest(ctx context.Context, proto6 *tfprotov6.PlanResour fw := &fwserver.PlanResourceChangeRequest{ ResourceBehavior: resourceBehavior, ResourceSchema: resourceSchema, + IdentitySchema: identitySchema, Resource: reqResource, ClientCapabilities: ModifyPlanClientCapabilities(proto6.ClientCapabilities), } @@ -57,6 +58,12 @@ func PlanResourceChangeRequest(ctx context.Context, proto6 *tfprotov6.PlanResour fw.PriorState = priorState + priorIdentity, priorIdentityDiags := ResourceIdentity(ctx, proto6.PriorIdentity, identitySchema) + + diags.Append(priorIdentityDiags...) + + fw.PriorIdentity = priorIdentity + proposedNewState, proposedNewStateDiags := Plan(ctx, proto6.ProposedNewState, resourceSchema) diags.Append(proposedNewStateDiags...) diff --git a/internal/fromproto6/planresourcechange_test.go b/internal/fromproto6/planresourcechange_test.go index af8ee025..3660535a 100644 --- a/internal/fromproto6/planresourcechange_test.go +++ b/internal/fromproto6/planresourcechange_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "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" ) @@ -48,6 +49,30 @@ func TestPlanResourceChangeRequest(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, + }, + }, + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -58,6 +83,7 @@ func TestPlanResourceChangeRequest(t *testing.T) { input *tfprotov6.PlanResourceChangeRequest resourceBehavior resource.ResourceBehavior resourceSchema fwschema.Schema + identitySchema fwschema.Schema resource resource.Resource providerMetaSchema fwschema.Schema expected *fwserver.PlanResourceChangeRequest @@ -182,6 +208,42 @@ func TestPlanResourceChangeRequest(t *testing.T) { ResourceSchema: testFwSchema, }, }, + "prioridentity-missing-schema": { + input: &tfprotov6.PlanResourceChangeRequest{ + PriorIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testIdentityProto6DynamicValue, + }, + }, + resourceSchema: testFwSchema, + expected: &fwserver.PlanResourceChangeRequest{ + ResourceSchema: testFwSchema, + }, + 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.", + ), + }, + }, + "prioridentity": { + input: &tfprotov6.PlanResourceChangeRequest{ + PriorIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testIdentityProto6DynamicValue, + }, + }, + identitySchema: testIdentitySchema, + resourceSchema: testFwSchema, + expected: &fwserver.PlanResourceChangeRequest{ + IdentitySchema: testIdentitySchema, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: testIdentityProto6Value, + Schema: testIdentitySchema, + }, + ResourceSchema: testFwSchema, + }, + }, "providermeta-missing-data": { input: &tfprotov6.PlanResourceChangeRequest{}, resourceSchema: testFwSchema, @@ -265,7 +327,7 @@ func TestPlanResourceChangeRequest(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto6.PlanResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema, testCase.resourceBehavior) + got, diags := fromproto6.PlanResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema, testCase.resourceBehavior, testCase.identitySchema) if diff := cmp.Diff(got, testCase.expected, cmp.AllowUnexported(privatestate.ProviderData{})); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fromproto6/readresource.go b/internal/fromproto6/readresource.go index a42f669d..11ea6d84 100644 --- a/internal/fromproto6/readresource.go +++ b/internal/fromproto6/readresource.go @@ -17,7 +17,7 @@ import ( // ReadResourceRequest returns the *fwserver.ReadResourceRequest // equivalent of a *tfprotov6.ReadResourceRequest. -func ReadResourceRequest(ctx context.Context, proto6 *tfprotov6.ReadResourceRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema) (*fwserver.ReadResourceRequest, diag.Diagnostics) { +func ReadResourceRequest(ctx context.Context, proto6 *tfprotov6.ReadResourceRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema, identitySchema fwschema.Schema) (*fwserver.ReadResourceRequest, diag.Diagnostics) { if proto6 == nil { return nil, nil } @@ -26,6 +26,7 @@ func ReadResourceRequest(ctx context.Context, proto6 *tfprotov6.ReadResourceRequ fw := &fwserver.ReadResourceRequest{ Resource: reqResource, + IdentitySchema: identitySchema, ClientCapabilities: ReadResourceClientCapabilities(proto6.ClientCapabilities), } @@ -35,6 +36,12 @@ func ReadResourceRequest(ctx context.Context, proto6 *tfprotov6.ReadResourceRequ fw.CurrentState = currentState + currentIdentity, currentIdentityDiags := ResourceIdentity(ctx, proto6.CurrentIdentity, identitySchema) + + diags.Append(currentIdentityDiags...) + + fw.CurrentIdentity = currentIdentity + providerMeta, providerMetaDiags := ProviderMeta(ctx, proto6.ProviderMeta, providerMetaSchema) diags.Append(providerMetaDiags...) diff --git a/internal/fromproto6/readresource_test.go b/internal/fromproto6/readresource_test.go index 5ae9e168..e24e9f5b 100644 --- a/internal/fromproto6/readresource_test.go +++ b/internal/fromproto6/readresource_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "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" ) @@ -48,6 +49,30 @@ func TestReadResourceRequest(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, + }, + }, + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -59,6 +84,7 @@ func TestReadResourceRequest(t *testing.T) { testCases := map[string]struct { input *tfprotov6.ReadResourceRequest resourceSchema fwschema.Schema + identitySchema fwschema.Schema resource resource.Resource providerMetaSchema fwschema.Schema expected *fwserver.ReadResourceRequest @@ -99,6 +125,37 @@ func TestReadResourceRequest(t *testing.T) { }, }, }, + "currentidentity-missing-schema": { + input: &tfprotov6.ReadResourceRequest{ + CurrentIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testIdentityProto6DynamicValue, + }, + }, + expected: &fwserver.ReadResourceRequest{}, + 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.", + ), + }, + }, + "currentidentity": { + input: &tfprotov6.ReadResourceRequest{ + CurrentIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testIdentityProto6DynamicValue, + }, + }, + identitySchema: testIdentitySchema, + expected: &fwserver.ReadResourceRequest{ + IdentitySchema: testIdentitySchema, + CurrentIdentity: &tfsdk.ResourceIdentity{ + Raw: testIdentityProto6Value, + Schema: testIdentitySchema, + }, + }, + }, "private-malformed-json": { input: &tfprotov6.ReadResourceRequest{ Private: []byte(`{`), @@ -200,7 +257,7 @@ func TestReadResourceRequest(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto6.ReadResourceRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema) + got, diags := fromproto6.ReadResourceRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema, testCase.identitySchema) if diff := cmp.Diff(got, testCase.expected, cmp.AllowUnexported(privatestate.ProviderData{})); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fromproto6/resource_identity.go b/internal/fromproto6/resource_identity.go new file mode 100644 index 00000000..d9fc1b4e --- /dev/null +++ b/internal/fromproto6/resource_identity.go @@ -0,0 +1,62 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto6 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// TODO:ResourceIdentity: Should we create a wrapping struct to contain the identity data? To match the protocol (in-case we want to introduce other identity things) +// - Need to think more on this (like what if we want to introduce display-only attributes) +// - If we introduce one, add a test as well. +func ResourceIdentity(ctx context.Context, in *tfprotov6.ResourceIdentityData, schema fwschema.Schema) (*tfsdk.ResourceIdentity, diag.Diagnostics) { + if in == nil { + return nil, nil + } + + return IdentityData(ctx, in.IdentityData, schema) +} + +// IdentityData returns the *tfsdk.ResourceIdentity for a *tfprotov6.DynamicValue and fwschema.Schema. +func IdentityData(ctx context.Context, proto6DynamicValue *tfprotov6.DynamicValue, schema fwschema.Schema) (*tfsdk.ResourceIdentity, diag.Diagnostics) { + if proto6DynamicValue == nil { + return nil, nil + } + + var diags diag.Diagnostics + + // Panic prevention here to simplify the calling implementations. + // This should not happen, but just in case. + if schema == nil { + diags.AddError( + "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.", + ) + + return nil, diags + } + + data, dynamicValueDiags := DynamicValue(ctx, proto6DynamicValue, schema, fwschemadata.DataDescriptionResourceIdentity) + + diags.Append(dynamicValueDiags...) + + if diags.HasError() { + return nil, diags + } + + fw := &tfsdk.ResourceIdentity{ + Raw: data.TerraformValue, + Schema: schema, + } + + return fw, diags +} diff --git a/internal/fromproto6/resource_identity_test.go b/internal/fromproto6/resource_identity_test.go new file mode 100644 index 00000000..51724170 --- /dev/null +++ b/internal/fromproto6/resource_identity_test.go @@ -0,0 +1,129 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto6_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto6" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestResourceIdentity(t *testing.T) { + t.Parallel() + + testProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute": tftypes.String, + }, + } + + testProto6Value := tftypes.NewValue(testProto6Type, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }) + + testProto6DynamicValue, err := tfprotov6.NewDynamicValue(testProto6Type, testProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testFwSchema := testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + RequiredForImport: true, + Type: types.StringType, + }, + }, + } + + testFwSchemaInvalid := testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + RequiredForImport: true, + Type: types.BoolType, + }, + }, + } + + testCases := map[string]struct { + input *tfprotov6.ResourceIdentityData + schema fwschema.Schema + expected *tfsdk.ResourceIdentity + expectedDiagnostics diag.Diagnostics + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &tfprotov6.ResourceIdentityData{}, + expected: nil, + }, + "missing-schema": { + input: &tfprotov6.ResourceIdentityData{ + IdentityData: &testProto6DynamicValue, + }, + expected: nil, + 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.", + ), + }, + }, + "invalid-schema": { + input: &tfprotov6.ResourceIdentityData{ + IdentityData: &testProto6DynamicValue, + }, + schema: testFwSchemaInvalid, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Resource Identity", + "An unexpected error was encountered when converting the resource identity from the protocol type. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Unable to unmarshal DynamicValue: AttributeName(\"test_attribute\"): couldn't decode bool: msgpack: invalid code=aa decoding bool", + ), + }, + }, + "valid": { + input: &tfprotov6.ResourceIdentityData{ + IdentityData: &testProto6DynamicValue, + }, + schema: testFwSchema, + expected: &tfsdk.ResourceIdentity{ + Raw: testProto6Value, + Schema: testFwSchema, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := fromproto6.ResourceIdentity(context.Background(), testCase.input, testCase.schema) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/internal/fwschemadata/data_description.go b/internal/fwschemadata/data_description.go index 70ae62c7..282a5332 100644 --- a/internal/fwschemadata/data_description.go +++ b/internal/fwschemadata/data_description.go @@ -19,6 +19,10 @@ const ( // DataDescriptionEphemeralResultData is used for Data that represents // the result of an ephemeral operation. DataDescriptionEphemeralResultData DataDescription = "ephemeral result data" + + // DataDescriptionResourceIdentity is used for Data that represents + // a managed resource identity. + DataDescriptionResourceIdentity DataDescription = "resource identity" ) // DataDescription is a human friendly type for Data. Used in error @@ -46,6 +50,8 @@ func (d DataDescription) Title() string { return "State" case DataDescriptionEphemeralResultData: return "Ephemeral Result Data" + case DataDescriptionResourceIdentity: + return "Resource Identity" default: return "Data" } diff --git a/internal/fwserver/server.go b/internal/fwserver/server.go index 22723f9f..b4b8a779 100644 --- a/internal/fwserver/server.go +++ b/internal/fwserver/server.go @@ -158,6 +158,15 @@ type Server struct { // access from race conditions. resourceSchemasMutex sync.RWMutex + // resourceIdentitySchemas is the cached Resource Identity Schemas for RPCs that need to + // convert resource identity data from the protocol. If not found, it will be + // fetched from the Resource IdentitySchema method. + resourceIdentitySchemas map[string]fwschema.Schema + + // resourceIdentitySchemasMutex is a mutex to protect concurrent resourceIdentitySchemas + // access from race conditions. + resourceIdentitySchemasMutex sync.RWMutex + // resourceFuncs is the cached Resource functions for RPCs that need to // access resources. If not found, it will be fetched from the // Provider.Resources() method. @@ -690,6 +699,67 @@ func (s *Server) ResourceSchemas(ctx context.Context) (map[string]fwschema.Schem return resourceSchemas, diags } +// ResourceIdentitySchema returns the Resource Identity Schema for the given type name and +// caches the result for later Identity operations. Identity is an optional feature for resources, +// so this function will return a nil schema with no diagnostics if the resource type doesn't define +// an identity schema. +func (s *Server) ResourceIdentitySchema(ctx context.Context, typeName string) (fwschema.Schema, diag.Diagnostics) { + s.resourceIdentitySchemasMutex.RLock() + resourceIdentitySchema, ok := s.resourceIdentitySchemas[typeName] + s.resourceIdentitySchemasMutex.RUnlock() + + if ok { + return resourceIdentitySchema, nil + } + + var diags diag.Diagnostics + + r, resourceDiags := s.Resource(ctx, typeName) + + diags.Append(resourceDiags...) + + if diags.HasError() { + return nil, diags + } + + resourceWithIdentity, ok := r.(resource.ResourceWithIdentity) + if !ok { + // It's valid for a resource to not have an identity, so cache and return a nil schema + s.resourceIdentitySchemasMutex.Lock() + if s.resourceIdentitySchemas == nil { + s.resourceIdentitySchemas = make(map[string]fwschema.Schema) + } + + s.resourceIdentitySchemas[typeName] = nil + s.resourceIdentitySchemasMutex.Unlock() + + return nil, nil + } + + identitySchemaReq := resource.IdentitySchemaRequest{} + identitySchemaResp := resource.IdentitySchemaResponse{} + + logging.FrameworkTrace(ctx, "Calling provider defined Resource IdentitySchema method", map[string]interface{}{logging.KeyResourceType: typeName}) + resourceWithIdentity.IdentitySchema(ctx, identitySchemaReq, &identitySchemaResp) + logging.FrameworkTrace(ctx, "Called provider defined Resource IdentitySchema method", map[string]interface{}{logging.KeyResourceType: typeName}) + + diags.Append(identitySchemaResp.Diagnostics...) + + if diags.HasError() { + return identitySchemaResp.IdentitySchema, diags + } + + s.resourceIdentitySchemasMutex.Lock() + if s.resourceIdentitySchemas == nil { + s.resourceIdentitySchemas = make(map[string]fwschema.Schema) + } + + s.resourceIdentitySchemas[typeName] = identitySchemaResp.IdentitySchema + s.resourceIdentitySchemasMutex.Unlock() + + return identitySchemaResp.IdentitySchema, diags +} + // ResourceIdentitySchemas returns a map of Resource Identity Schemas for the // GetResourceIdentitySchemas RPC without caching since not all schemas are guaranteed to // be necessary for later provider operations. The schema implementations are diff --git a/internal/fwserver/server_applyresourcechange.go b/internal/fwserver/server_applyresourcechange.go index a11a72e4..2167ab70 100644 --- a/internal/fwserver/server_applyresourcechange.go +++ b/internal/fwserver/server_applyresourcechange.go @@ -17,13 +17,15 @@ import ( // ApplyResourceChangeRequest is the framework server request for the // ApplyResourceChange RPC. type ApplyResourceChangeRequest struct { - Config *tfsdk.Config - PlannedPrivate *privatestate.Data - PlannedState *tfsdk.Plan - PriorState *tfsdk.State - ProviderMeta *tfsdk.Config - ResourceSchema fwschema.Schema - Resource resource.Resource + Config *tfsdk.Config + PlannedPrivate *privatestate.Data + PlannedState *tfsdk.Plan + PlannedIdentity *tfsdk.ResourceIdentity + PriorState *tfsdk.State + ProviderMeta *tfsdk.Config + ResourceSchema fwschema.Schema + IdentitySchema fwschema.Schema + Resource resource.Resource } // ApplyResourceChangeResponse is the framework server response for the @@ -31,6 +33,7 @@ type ApplyResourceChangeRequest struct { type ApplyResourceChangeResponse struct { Diagnostics diag.Diagnostics NewState *tfsdk.State + NewIdentity *tfsdk.ResourceIdentity Private *privatestate.Data } @@ -45,12 +48,14 @@ func (s *Server) ApplyResourceChange(ctx context.Context, req *ApplyResourceChan logging.FrameworkTrace(ctx, "ApplyResourceChange received no PriorState, running CreateResource") createReq := &CreateResourceRequest{ - Config: req.Config, - PlannedPrivate: req.PlannedPrivate, - PlannedState: req.PlannedState, - ProviderMeta: req.ProviderMeta, - ResourceSchema: req.ResourceSchema, - Resource: req.Resource, + Config: req.Config, + PlannedPrivate: req.PlannedPrivate, + PlannedState: req.PlannedState, + PlannedIdentity: req.PlannedIdentity, + ProviderMeta: req.ProviderMeta, + ResourceSchema: req.ResourceSchema, + IdentitySchema: req.IdentitySchema, + Resource: req.Resource, } createResp := &CreateResourceResponse{} @@ -58,6 +63,7 @@ func (s *Server) ApplyResourceChange(ctx context.Context, req *ApplyResourceChan resp.Diagnostics = createResp.Diagnostics resp.NewState = createResp.NewState + resp.NewIdentity = createResp.NewIdentity resp.Private = createResp.Private return @@ -72,6 +78,7 @@ func (s *Server) ApplyResourceChange(ctx context.Context, req *ApplyResourceChan PriorState: req.PriorState, ProviderMeta: req.ProviderMeta, ResourceSchema: req.ResourceSchema, + IdentitySchema: req.IdentitySchema, Resource: req.Resource, } deleteResp := &DeleteResourceResponse{} @@ -80,6 +87,7 @@ func (s *Server) ApplyResourceChange(ctx context.Context, req *ApplyResourceChan resp.Diagnostics = deleteResp.Diagnostics resp.NewState = deleteResp.NewState + resp.NewIdentity = deleteResp.NewIdentity resp.Private = deleteResp.Private return @@ -89,13 +97,15 @@ func (s *Server) ApplyResourceChange(ctx context.Context, req *ApplyResourceChan logging.FrameworkTrace(ctx, "ApplyResourceChange running UpdateResource") updateReq := &UpdateResourceRequest{ - Config: req.Config, - PlannedPrivate: req.PlannedPrivate, - PlannedState: req.PlannedState, - PriorState: req.PriorState, - ProviderMeta: req.ProviderMeta, - ResourceSchema: req.ResourceSchema, - Resource: req.Resource, + Config: req.Config, + PlannedPrivate: req.PlannedPrivate, + PlannedState: req.PlannedState, + PlannedIdentity: req.PlannedIdentity, + PriorState: req.PriorState, + ProviderMeta: req.ProviderMeta, + ResourceSchema: req.ResourceSchema, + IdentitySchema: req.IdentitySchema, + Resource: req.Resource, } updateResp := &UpdateResourceResponse{} @@ -103,5 +113,6 @@ func (s *Server) ApplyResourceChange(ctx context.Context, req *ApplyResourceChan resp.Diagnostics = updateResp.Diagnostics resp.NewState = updateResp.NewState + resp.NewIdentity = updateResp.NewIdentity resp.Private = updateResp.Private } diff --git a/internal/fwserver/server_applyresourcechange_test.go b/internal/fwserver/server_applyresourcechange_test.go index ea445224..a8565de7 100644 --- a/internal/fwserver/server_applyresourcechange_test.go +++ b/internal/fwserver/server_applyresourcechange_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "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" "github.com/hashicorp/terraform-plugin-framework/types" @@ -33,6 +34,12 @@ func TestServerApplyResourceChange(t *testing.T) { }, } + testIdentityType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + testSchema := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_computed": schema.StringAttribute{ @@ -44,6 +51,14 @@ func TestServerApplyResourceChange(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testEmptyPlan := &tfsdk.Plan{ Raw: tftypes.NewValue(testSchemaType, nil), Schema: testSchema, @@ -84,6 +99,10 @@ func TestServerApplyResourceChange(t *testing.T) { TestRequiredWriteOnly types.String `tfsdk:"test_required_write_only"` } + type testIdentitySchemaData struct { + TestID types.String `tfsdk:"test_id"` + } + testProviderMetaType := tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "test_provider_meta_attribute": tftypes.String, @@ -243,6 +262,69 @@ func TestServerApplyResourceChange(t *testing.T) { }, Private: testEmptyPrivate}, }, + "create-request-plannedidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ApplyResourceChangeRequest{ + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + PriorState: testEmptyState, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var identityData testIdentitySchemaData + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + if identityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("Unexpected req.Identity Value", "Got: "+identityData.TestID.ValueString()) + } + + // Prevent missing resource state error diagnostic + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + DeleteMethod: func(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Delete") + }, + UpdateMethod: func(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Update") + }, + }, + }, + }, + expectedResponse: &fwserver.ApplyResourceChangeResponse{ + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + Private: testEmptyPrivate}, + }, "create-request-providermeta": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -376,6 +458,67 @@ func TestServerApplyResourceChange(t *testing.T) { Private: testEmptyPrivate, }, }, + "create-response-newidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ApplyResourceChangeRequest{ + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + IdentitySchema: testIdentitySchema, + PriorState: testEmptyState, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + resp.Diagnostics.Append(resp.Identity.Set(ctx, testIdentitySchemaData{ + TestID: types.StringValue("new-id-123"), + })...) + + // Prevent missing resource state error diagnostic + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + DeleteMethod: func(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Delete") + }, + UpdateMethod: func(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Update") + }, + }, + }, + }, + expectedResponse: &fwserver.ApplyResourceChangeResponse{ + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + }, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + Private: testEmptyPrivate, + }, + }, "create-response-newstate-null": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -826,6 +969,48 @@ func TestServerApplyResourceChange(t *testing.T) { NewState: testEmptyState, }, }, + "delete-response-newidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ApplyResourceChangeRequest{ + PlannedState: testEmptyPlan, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-priorstate-value"), + }), + Schema: testSchema, + }, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + CreateMethod: func(_ context.Context, _ resource.CreateRequest, resp *resource.CreateResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Delete, Got: Create") + }, + DeleteMethod: func(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + if resp.Identity == nil || !resp.Identity.Raw.IsNull() { + resp.Diagnostics.AddError( + "Unexpected resp.Identity", + "expected resp.Identity to be a null object of the schema type.", + ) + } + }, + UpdateMethod: func(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Delete, Got: Update") + }, + }, + }, + }, + expectedResponse: &fwserver.ApplyResourceChangeResponse{ + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, nil), + Schema: testIdentitySchema, + }, + NewState: testEmptyState, + }, + }, "update-request-config": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -940,6 +1125,77 @@ func TestServerApplyResourceChange(t *testing.T) { Private: testEmptyPrivate, }, }, + "update-request-plannedidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ApplyResourceChangeRequest{ + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-new-value"), + }), + Schema: testSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-new-value"), + }), + Schema: testSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-old-value"), + }), + Schema: testSchema, + }, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + CreateMethod: func(_ context.Context, _ resource.CreateRequest, resp *resource.CreateResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Update, Got: Create") + }, + DeleteMethod: func(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Update, Got: Delete") + }, + UpdateMethod: func(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var identityData testIdentitySchemaData + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + if identityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("Unexpected req.Identity Value", "Got: "+identityData.TestID.ValueString()) + } + }, + }, + }, + }, + expectedResponse: &fwserver.ApplyResourceChangeResponse{ + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-old-value"), + }), + Schema: testSchema, + }, + Private: testEmptyPrivate, + }, + }, "update-request-priorstate": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -1280,6 +1536,67 @@ func TestServerApplyResourceChange(t *testing.T) { Private: testEmptyPrivate, }, }, + "update-response-newidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ApplyResourceChangeRequest{ + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-new-value"), + }), + Schema: testSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-new-value"), + }), + Schema: testSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-old-value"), + }), + Schema: testSchema, + }, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + CreateMethod: func(_ context.Context, _ resource.CreateRequest, resp *resource.CreateResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Update, Got: Create") + }, + DeleteMethod: func(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Update, Got: Delete") + }, + UpdateMethod: func(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.Append(resp.Identity.Set(ctx, testIdentitySchemaData{ + TestID: types.StringValue("new-id-123"), + })...) + }, + }, + }, + }, + expectedResponse: &fwserver.ApplyResourceChangeResponse{ + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + }, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-old-value"), + }), + Schema: testSchema, + }, + Private: testEmptyPrivate, + }, + }, "update-response-newstate-null": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, diff --git a/internal/fwserver/server_createresource.go b/internal/fwserver/server_createresource.go index d5a0aef2..0e5b873a 100644 --- a/internal/fwserver/server_createresource.go +++ b/internal/fwserver/server_createresource.go @@ -20,12 +20,14 @@ import ( // CreateResourceRequest is the framework server request for a create request // with the ApplyResourceChange RPC. type CreateResourceRequest struct { - Config *tfsdk.Config - PlannedPrivate *privatestate.Data - PlannedState *tfsdk.Plan - ProviderMeta *tfsdk.Config - ResourceSchema fwschema.Schema - Resource resource.Resource + Config *tfsdk.Config + PlannedPrivate *privatestate.Data + PlannedState *tfsdk.Plan + PlannedIdentity *tfsdk.ResourceIdentity + ProviderMeta *tfsdk.Config + ResourceSchema fwschema.Schema + IdentitySchema fwschema.Schema + Resource resource.Resource } // CreateResourceResponse is the framework server response for a create request @@ -33,6 +35,7 @@ type CreateResourceRequest struct { type CreateResourceResponse struct { Diagnostics diag.Diagnostics NewState *tfsdk.State + NewIdentity *tfsdk.ResourceIdentity Private *privatestate.Data } @@ -97,12 +100,37 @@ func (s *Server) CreateResource(ctx context.Context, req *CreateResourceRequest, createReq.ProviderMeta = *req.ProviderMeta } + // If the resource supports identity and there is no planned identity data, pre-populate with a null value. + // TODO:ResourceIdentity: This logic is likely useless since plan should already handle this, probably should remove. + if req.PlannedIdentity == nil && req.IdentitySchema != nil { + nullIdentityTfValue := tftypes.NewValue(req.IdentitySchema.Type().TerraformType(ctx), nil) + + req.PlannedIdentity = &tfsdk.ResourceIdentity{ + Schema: req.IdentitySchema, + Raw: nullIdentityTfValue.Copy(), + } + } + + // Pre-populate the new identity with the planned identity. + if req.PlannedIdentity != nil { + createReq.Identity = &tfsdk.ResourceIdentity{ + Schema: req.PlannedIdentity.Schema, + Raw: req.PlannedIdentity.Raw.Copy(), + } + + createResp.Identity = &tfsdk.ResourceIdentity{ + Schema: req.PlannedIdentity.Schema, + Raw: req.PlannedIdentity.Raw.Copy(), + } + } + logging.FrameworkTrace(ctx, "Calling provider defined Resource Create") req.Resource.Create(ctx, createReq, &createResp) logging.FrameworkTrace(ctx, "Called provider defined Resource Create") resp.Diagnostics = createResp.Diagnostics resp.NewState = &createResp.State + resp.NewIdentity = createResp.Identity if !resp.Diagnostics.HasError() && createResp.State.Raw.Equal(nullSchemaData) { detail := "The Terraform Provider unexpectedly returned no resource state after having no errors in the resource creation. " + @@ -132,6 +160,16 @@ func (s *Server) CreateResource(ctx context.Context, req *CreateResourceRequest, return } + if resp.NewIdentity != nil && req.IdentitySchema == nil { + resp.Diagnostics.AddError( + "Unexpected Create Response", + "An unexpected error was encountered when creating the apply response. New identity data was returned by the provider create 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 + } + semanticEqualityReq := SchemaSemanticEqualityRequest{ PriorData: fwschemadata.Data{ Description: fwschemadata.DataDescriptionPlan, diff --git a/internal/fwserver/server_createresource_test.go b/internal/fwserver/server_createresource_test.go index 86bedcb8..3095638f 100644 --- a/internal/fwserver/server_createresource_test.go +++ b/internal/fwserver/server_createresource_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "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" "github.com/hashicorp/terraform-plugin-framework/types" @@ -33,6 +34,12 @@ func TestServerCreateResource(t *testing.T) { }, } + testIdentityType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + testSchemaTypeWriteOnly := tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "test_required": tftypes.String, @@ -51,6 +58,14 @@ func TestServerCreateResource(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testSchemaWithSemanticEquals := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_computed": schema.StringAttribute{ @@ -105,6 +120,10 @@ func TestServerCreateResource(t *testing.T) { TestRequired types.String `tfsdk:"test_required"` } + type testIdentitySchemaData struct { + TestID types.String `tfsdk:"test_id"` + } + type testSchemaDataWithSemanticEquals struct { TestComputed types.String `tfsdk:"test_computed"` TestRequired testtypes.StringValueWithSemanticEquals `tfsdk:"test_required"` @@ -244,6 +263,63 @@ func TestServerCreateResource(t *testing.T) { Private: testEmptyPrivate, }, }, + "request-plannedidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.CreateResourceRequest{ + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var identityData testIdentitySchemaData + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + if identityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("Unexpected req.Identity Value", "Got: "+identityData.TestID.ValueString()) + } + + // Prevent missing resource state error diagnostic + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + }, + }, + }, + expectedResponse: &fwserver.CreateResourceResponse{ + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + Private: testEmptyPrivate, + }, + }, "request-providermeta": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -457,6 +533,110 @@ func TestServerCreateResource(t *testing.T) { Private: testEmptyPrivate, }, }, + "response-newidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.CreateResourceRequest{ + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + resp.Diagnostics.Append(resp.Identity.Set(ctx, testIdentitySchemaData{ + TestID: types.StringValue("new-id-123"), + })...) + + // Prevent missing resource state error diagnostic + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + }, + }, + }, + expectedResponse: &fwserver.CreateResourceResponse{ + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + }, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + Private: testEmptyPrivate, + }, + }, + "response-invalid-newidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.CreateResourceRequest{ + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + // This resource doesn't indicate identity support (via a schema), so this should raise a diagnostic. + resp.Identity = &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + } + + // Prevent missing resource state error diagnostic + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + }, + }, + }, + expectedResponse: &fwserver.CreateResourceResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unexpected Create Response", + "An unexpected error was encountered when creating the apply response. New identity data was returned by the provider create 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.", + ), + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + }, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + Private: testEmptyPrivate, + }, + }, "response-newstate-null": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, diff --git a/internal/fwserver/server_deleteresource.go b/internal/fwserver/server_deleteresource.go index 5879b670..d556badf 100644 --- a/internal/fwserver/server_deleteresource.go +++ b/internal/fwserver/server_deleteresource.go @@ -23,6 +23,7 @@ type DeleteResourceRequest struct { PriorState *tfsdk.State ProviderMeta *tfsdk.Config ResourceSchema fwschema.Schema + IdentitySchema fwschema.Schema Resource resource.Resource } @@ -31,6 +32,7 @@ type DeleteResourceRequest struct { type DeleteResourceResponse struct { Diagnostics diag.Diagnostics NewState *tfsdk.State + NewIdentity *tfsdk.ResourceIdentity Private *privatestate.Data } @@ -96,6 +98,17 @@ func (s *Server) DeleteResource(ctx context.Context, req *DeleteResourceRequest, resp.Private = req.PlannedPrivate } + // If the resource supports identity pre-populate a null value. + // TODO:ResourceIdentity: This should probably be prior identity, but we don't currently have that in the protocol. + if req.IdentitySchema != nil { + nullIdentityTfValue := tftypes.NewValue(req.IdentitySchema.Type().TerraformType(ctx), nil) + + deleteResp.Identity = &tfsdk.ResourceIdentity{ + Schema: req.IdentitySchema, + Raw: nullIdentityTfValue.Copy(), + } + } + logging.FrameworkTrace(ctx, "Calling provider defined Resource Delete") req.Resource.Delete(ctx, deleteReq, &deleteResp) logging.FrameworkTrace(ctx, "Called provider defined Resource Delete") @@ -108,10 +121,21 @@ func (s *Server) DeleteResource(ctx context.Context, req *DeleteResourceRequest, // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/863 deleteResp.Private = nil resp.Private = nil + + // If the resource supports identity send a null value. + if req.IdentitySchema != nil { + nullIdentityTfValue := tftypes.NewValue(req.IdentitySchema.Type().TerraformType(ctx), nil) + + deleteResp.Identity = &tfsdk.ResourceIdentity{ + Schema: req.IdentitySchema, + Raw: nullIdentityTfValue.Copy(), + } + } } resp.Diagnostics = deleteResp.Diagnostics resp.NewState = &deleteResp.State + resp.NewIdentity = deleteResp.Identity if deleteResp.Private != nil { if resp.Private == nil { @@ -120,4 +144,14 @@ func (s *Server) DeleteResource(ctx context.Context, req *DeleteResourceRequest, resp.Private.Provider = deleteResp.Private } + + if resp.NewIdentity != nil && req.IdentitySchema == nil { + resp.Diagnostics.AddError( + "Unexpected Delete Response", + "An unexpected error was encountered when creating the apply response. New identity data was returned by the provider delete 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 + } } diff --git a/internal/fwserver/server_deleteresource_test.go b/internal/fwserver/server_deleteresource_test.go index 2042e276..b47c16c0 100644 --- a/internal/fwserver/server_deleteresource_test.go +++ b/internal/fwserver/server_deleteresource_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "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" "github.com/hashicorp/terraform-plugin-framework/types" @@ -33,6 +34,12 @@ func TestServerDeleteResource(t *testing.T) { }, } + testIdentityType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + testSchema := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_computed": schema.StringAttribute{ @@ -44,6 +51,14 @@ func TestServerDeleteResource(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testEmptyState := &tfsdk.State{ Raw: tftypes.NewValue(testSchemaType, nil), Schema: testSchema, @@ -54,6 +69,10 @@ func TestServerDeleteResource(t *testing.T) { TestRequired types.String `tfsdk:"test_required"` } + type testIdentitySchemaData struct { + TestID types.String `tfsdk:"test_id"` + } + testProviderMetaType := tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "test_provider_meta_attribute": tftypes.String, @@ -334,6 +353,114 @@ func TestServerDeleteResource(t *testing.T) { NewState: testEmptyState, }, }, + "response-newidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.DeleteResourceRequest{ + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-priorstate-value"), + }), + Schema: testSchema, + }, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.Resource{ + DeleteMethod: func(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + if resp.Identity == nil || !resp.Identity.Raw.IsNull() { + resp.Diagnostics.AddError( + "Unexpected resp.Identity", + "expected resp.Identity to be a null object of the schema type.", + ) + } + }, + }, + }, + expectedResponse: &fwserver.DeleteResourceResponse{ + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, nil), + Schema: testIdentitySchema, + }, + NewState: testEmptyState, + }, + }, + "response-newidentity-set-to-null": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.DeleteResourceRequest{ + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-priorstate-value"), + }), + Schema: testSchema, + }, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.Resource{ + DeleteMethod: func(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // This should be nulled out + resp.Diagnostics.Append(resp.Identity.Set(ctx, testIdentitySchemaData{ + TestID: types.StringValue("new-id-123"), + })...) + }, + }, + }, + expectedResponse: &fwserver.DeleteResourceResponse{ + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, nil), + Schema: testIdentitySchema, + }, + NewState: testEmptyState, + }, + }, + "response-invalid-newidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.DeleteResourceRequest{ + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-priorstate-value"), + }), + Schema: testSchema, + }, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + DeleteMethod: func(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // This should raise a diagnostic + resp.Identity = &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + } + }, + }, + }, + }, + expectedResponse: &fwserver.DeleteResourceResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unexpected Delete Response", + "An unexpected error was encountered when creating the apply response. New identity data was returned by the provider delete 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.", + ), + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + }, + NewState: testEmptyState, + }, + }, "response-private": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -375,7 +502,7 @@ func TestServerDeleteResource(t *testing.T) { Private: testPrivateProvider, }, }, - "response-private-updated": { + "response-private-Deleted": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, }, diff --git a/internal/fwserver/server_planresourcechange.go b/internal/fwserver/server_planresourcechange.go index 52b06688..7910bac8 100644 --- a/internal/fwserver/server_planresourcechange.go +++ b/internal/fwserver/server_planresourcechange.go @@ -30,9 +30,11 @@ type PlanResourceChangeRequest struct { Config *tfsdk.Config PriorPrivate *privatestate.Data PriorState *tfsdk.State + PriorIdentity *tfsdk.ResourceIdentity ProposedNewState *tfsdk.Plan ProviderMeta *tfsdk.Config ResourceSchema fwschema.Schema + IdentitySchema fwschema.Schema Resource resource.Resource ResourceBehavior resource.ResourceBehavior } @@ -44,6 +46,7 @@ type PlanResourceChangeResponse struct { Diagnostics diag.Diagnostics PlannedPrivate *privatestate.Data PlannedState *tfsdk.State + PlannedIdentity *tfsdk.ResourceIdentity RequiresReplace path.Paths } @@ -115,6 +118,26 @@ func (s *Server) PlanResourceChange(ctx context.Context, req *PlanResourceChange } } + // If the resource supports identity and there is no prior identity data, 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? + // TODO:ResourceIdentity: Should this be set to all unknowns? + if req.PriorIdentity == nil && req.IdentitySchema != nil { + nullIdentityTfValue := tftypes.NewValue(req.IdentitySchema.Type().TerraformType(ctx), nil) + + req.PriorIdentity = &tfsdk.ResourceIdentity{ + Schema: req.IdentitySchema, + Raw: nullIdentityTfValue.Copy(), + } + } + + // Set the planned identity to the prior identity by default (can be modified later). + if req.PriorIdentity != nil { + resp.PlannedIdentity = &tfsdk.ResourceIdentity{ + Schema: req.PriorIdentity.Schema, + Raw: req.PriorIdentity.Raw.Copy(), + } + } + // Ensure that resp.PlannedPrivate is never nil. resp.PlannedPrivate = privatestate.EmptyData(ctx) @@ -304,9 +327,17 @@ func (s *Server) PlanResourceChange(ctx context.Context, req *PlanResourceChange modifyPlanReq.ProviderMeta = *req.ProviderMeta } + if resp.PlannedIdentity != nil { + modifyPlanReq.Identity = &tfsdk.ResourceIdentity{ + Schema: resp.PlannedIdentity.Schema, + Raw: resp.PlannedIdentity.Raw.Copy(), + } + } + modifyPlanResp := resource.ModifyPlanResponse{ Diagnostics: resp.Diagnostics, Plan: modifyPlanReq.Plan, + Identity: modifyPlanReq.Identity, RequiresReplace: path.Paths{}, Private: modifyPlanReq.Private, } @@ -317,6 +348,7 @@ func (s *Server) PlanResourceChange(ctx context.Context, req *PlanResourceChange resp.Diagnostics = modifyPlanResp.Diagnostics resp.PlannedState = planToState(modifyPlanResp.Plan) + resp.PlannedIdentity = modifyPlanResp.Identity resp.RequiresReplace = append(resp.RequiresReplace, modifyPlanResp.RequiresReplace...) resp.PlannedPrivate.Provider = modifyPlanResp.Private resp.Deferred = modifyPlanResp.Deferred @@ -338,6 +370,16 @@ func (s *Server) PlanResourceChange(ctx context.Context, req *PlanResourceChange } } + if resp.PlannedIdentity != nil && req.IdentitySchema == nil { + resp.Diagnostics.AddError( + "Unexpected Plan Response", + "An unexpected error was encountered when creating the plan response. New identity data was returned by the provider planning 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 + } + // Ensure deterministic RequiresReplace by sorting and deduplicating resp.RequiresReplace = NormaliseRequiresReplace(ctx, resp.RequiresReplace) diff --git a/internal/fwserver/server_planresourcechange_test.go b/internal/fwserver/server_planresourcechange_test.go index f1607dae..e8cbf3ad 100644 --- a/internal/fwserver/server_planresourcechange_test.go +++ b/internal/fwserver/server_planresourcechange_test.go @@ -24,6 +24,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "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/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicdefault" @@ -437,6 +438,12 @@ func TestServerPlanResourceChange(t *testing.T) { }, } + testIdentitySchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + testSchemaTypeWriteOnly := tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "test_computed": tftypes.String, @@ -574,6 +581,14 @@ func TestServerPlanResourceChange(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testSchemaWriteOnly := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_computed": schema.StringAttribute{ @@ -1107,6 +1122,10 @@ func TestServerPlanResourceChange(t *testing.T) { TestRequired types.String `tfsdk:"test_required"` } + type testIdentitySchemaData struct { + TestID types.String `tfsdk:"test_id"` + } + type testSchemaDataBlock struct { TestRequired types.String `tfsdk:"test_required"` TestOptionalBlock types.Object `tfsdk:"test_optional_block"` @@ -3072,6 +3091,66 @@ func TestServerPlanResourceChange(t *testing.T) { PlannedPrivate: testEmptyPrivate, }, }, + "create-resourcewithmodifyplan-request-prioridentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanResourceChangeRequest{ + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + ProposedNewState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PriorState: testEmptyState, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentityAndModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + var data testIdentitySchemaData + + resp.Diagnostics.Append(req.Identity.Get(ctx, &data)...) + + if data.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("Unexpected req.Identity Value", "Got: "+data.TestID.ValueString()) + } + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + }, + }, + expectedResponse: &fwserver.PlanResourceChangeResponse{ + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + PlannedPrivate: testEmptyPrivate, + }, + }, "create-resourcewithmodifyplan-request-providermeta": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -3420,6 +3499,124 @@ func TestServerPlanResourceChange(t *testing.T) { PlannedPrivate: testEmptyPrivate, }, }, + "create-resourcewithmodifyplan-response-plannedidentity-new": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanResourceChangeRequest{ + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + ProposedNewState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PriorState: testEmptyState, + // Resource supports identity but there isn't one in state yet + PriorIdentity: nil, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentityAndModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if !req.Identity.Raw.IsNull() { + resp.Diagnostics.AddError("Unexpected request", "expected req.Identity to be null") + return + } + + data := testIdentitySchemaData{ + TestID: types.StringValue("new-id-123"), + } + + resp.Diagnostics.Append(req.Identity.Set(ctx, &data)...) + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + }, + }, + expectedResponse: &fwserver.PlanResourceChangeResponse{ + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + }, + PlannedPrivate: testEmptyPrivate, + }, + }, + "create-resourcewithmodifyplan-response-plannedidentity-update": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanResourceChangeRequest{ + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + ProposedNewState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PriorState: testEmptyState, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentityAndModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + var data testIdentitySchemaData + resp.Diagnostics.Append(req.Identity.Get(ctx, &data)...) + + data.TestID = types.StringValue("new-id-123") + + resp.Diagnostics.Append(req.Identity.Set(ctx, &data)...) + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + }, + }, + expectedResponse: &fwserver.PlanResourceChangeResponse{ + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + }, + PlannedPrivate: testEmptyPrivate, + }, + }, "create-resourcewithmodifyplan-response-private": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -3556,6 +3753,63 @@ func TestServerPlanResourceChange(t *testing.T) { PlannedPrivate: testPrivateProvider, }, }, + "create-resourcewithmodifyplan-response-invalid-identity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanResourceChangeRequest{ + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + ProposedNewState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PriorState: testEmptyState, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + // This resource doesn't indicate identity support (via a schema), so this should raise a diagnostic. + resp.Identity = &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + } + }, + }, + }, + expectedResponse: &fwserver.PlanResourceChangeResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unexpected Plan Response", + "An unexpected error was encountered when creating the plan response. New identity data was returned by the provider planning 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.", + ), + }, + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + }, + PlannedPrivate: testEmptyPrivate, + }, + }, "delete-resourcewithmodifyplan-request-config": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -5704,6 +5958,13 @@ func TestServerPlanResourceChange(t *testing.T) { }), Schema: testSchemaAttributePlanModifierAttributePlan, }, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + IdentitySchema: testIdentitySchema, ResourceSchema: testSchemaAttributePlanModifierAttributePlan, Resource: &testprovider.Resource{}, }, @@ -5722,6 +5983,12 @@ func TestServerPlanResourceChange(t *testing.T) { }), Schema: testSchemaAttributePlanModifierAttributePlan, }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, PlannedPrivate: testEmptyPrivate, }, }, diff --git a/internal/fwserver/server_readresource.go b/internal/fwserver/server_readresource.go index d260e291..1c56d44c 100644 --- a/internal/fwserver/server_readresource.go +++ b/internal/fwserver/server_readresource.go @@ -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/fwschemadata" "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" @@ -20,6 +21,8 @@ import ( // ReadResource RPC. type ReadResourceRequest struct { ClientCapabilities resource.ReadClientCapabilities + IdentitySchema fwschema.Schema + CurrentIdentity *tfsdk.ResourceIdentity CurrentState *tfsdk.State Resource resource.Resource Private *privatestate.Data @@ -32,6 +35,7 @@ type ReadResourceResponse struct { Deferred *resource.Deferred Diagnostics diag.Diagnostics NewState *tfsdk.State + NewIdentity *tfsdk.ResourceIdentity Private *privatestate.Data } @@ -115,12 +119,36 @@ func (s *Server) ReadResource(ctx context.Context, req *ReadResourceRequest, res resp.Private = req.Private } + // If the resource supports identity and there is no current identity data, 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.CurrentIdentity == nil && req.IdentitySchema != nil { + nullTfValue := tftypes.NewValue(req.IdentitySchema.Type().TerraformType(ctx), nil) + + req.CurrentIdentity = &tfsdk.ResourceIdentity{ + Schema: req.IdentitySchema, + Raw: nullTfValue.Copy(), + } + } + + if req.CurrentIdentity != nil { + readReq.Identity = &tfsdk.ResourceIdentity{ + Schema: req.CurrentIdentity.Schema, + Raw: req.CurrentIdentity.Raw.Copy(), + } + + readResp.Identity = &tfsdk.ResourceIdentity{ + Schema: req.CurrentIdentity.Schema, + Raw: req.CurrentIdentity.Raw.Copy(), + } + } + logging.FrameworkTrace(ctx, "Calling provider defined Resource Read") req.Resource.Read(ctx, readReq, &readResp) logging.FrameworkTrace(ctx, "Called provider defined Resource Read") resp.Diagnostics = readResp.Diagnostics resp.NewState = &readResp.State + resp.NewIdentity = readResp.Identity resp.Deferred = readResp.Deferred if readResp.Private != nil { @@ -135,6 +163,16 @@ func (s *Server) ReadResource(ctx context.Context, req *ReadResourceRequest, res return } + if resp.NewIdentity != nil && req.IdentitySchema == nil { + resp.Diagnostics.AddError( + "Unexpected Read Response", + "An unexpected error was encountered when creating the read response. New identity data was returned by the provider read 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 + } + semanticEqualityReq := SchemaSemanticEqualityRequest{ PriorData: fwschemadata.Data{ Description: fwschemadata.DataDescriptionState, diff --git a/internal/fwserver/server_readresource_test.go b/internal/fwserver/server_readresource_test.go index 9f3aa706..a846d389 100644 --- a/internal/fwserver/server_readresource_test.go +++ b/internal/fwserver/server_readresource_test.go @@ -19,6 +19,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" "github.com/hashicorp/terraform-plugin-framework/provider" "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" "github.com/hashicorp/terraform-plugin-framework/types" @@ -34,6 +35,12 @@ func TestServerReadResource(t *testing.T) { }, } + testIdentityType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + testTypeWriteOnly := tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "test_write_only": tftypes.String, @@ -46,6 +53,10 @@ func TestServerReadResource(t *testing.T) { "test_required": tftypes.NewValue(tftypes.String, "test-currentstate-value"), }) + testCurrentIdentityValue := tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + testCurrentStateValueWriteOnly := tftypes.NewValue(testTypeWriteOnly, map[string]tftypes.Value{ "test_write_only": tftypes.NewValue(tftypes.String, nil), "test_required": tftypes.NewValue(tftypes.String, "test-currentstate-value"), @@ -56,6 +67,10 @@ func TestServerReadResource(t *testing.T) { "test_required": tftypes.NewValue(tftypes.String, "test-currentstate-value"), }) + testNewIdentityValue := tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }) + testSchema := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_computed": schema.StringAttribute{ @@ -67,6 +82,14 @@ func TestServerReadResource(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testSchemaWriteOnly := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_write_only": schema.StringAttribute{ @@ -121,6 +144,11 @@ func TestServerReadResource(t *testing.T) { Schema: testSchema, } + testCurrentIdentity := &tfsdk.ResourceIdentity{ + Raw: testCurrentIdentityValue, + Schema: testIdentitySchema, + } + testCurrentStateWriteOnly := &tfsdk.State{ Raw: testCurrentStateValueWriteOnly, Schema: testSchemaWriteOnly, @@ -131,6 +159,11 @@ func TestServerReadResource(t *testing.T) { Schema: testSchema, } + testNewIdentity := &tfsdk.ResourceIdentity{ + Raw: testNewIdentityValue, + Schema: testIdentitySchema, + } + testNewStateRemoved := &tfsdk.State{ Raw: tftypes.NewValue(testType, nil), Schema: testSchema, @@ -249,6 +282,36 @@ func TestServerReadResource(t *testing.T) { Private: testEmptyPrivate, }, }, + "request-currentidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ReadResourceRequest{ + CurrentState: testCurrentState, + IdentitySchema: testIdentitySchema, + CurrentIdentity: testCurrentIdentity, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + ReadMethod: func(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var identityData struct { + TestID types.String `tfsdk:"test_id"` + } + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + if identityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("unexpected req.Identity value: %s", identityData.TestID.ValueString()) + } + }, + }, + }, + }, + expectedResponse: &fwserver.ReadResourceResponse{ + NewState: testCurrentState, + NewIdentity: testCurrentIdentity, + Private: testEmptyPrivate, + }, + }, "request-providermeta": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -532,6 +595,99 @@ func TestServerReadResource(t *testing.T) { Private: testEmptyPrivate, }, }, + "response-identity-new": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ReadResourceRequest{ + CurrentState: testCurrentState, + // Resource supports identity but there isn't one in state yet + CurrentIdentity: nil, + IdentitySchema: testIdentitySchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + ReadMethod: func(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + if !req.Identity.Raw.IsNull() { + resp.Diagnostics.AddError("Unexpected request", "expected req.Identity to be null") + return + } + + identityData := struct { + TestID types.String `tfsdk:"test_id"` + }{ + TestID: types.StringValue("new-id-123"), + } + + resp.Diagnostics.Append(resp.Identity.Set(ctx, &identityData)...) + }, + }, + }, + }, + expectedResponse: &fwserver.ReadResourceResponse{ + NewState: testCurrentState, + NewIdentity: testNewIdentity, + Private: testEmptyPrivate, + }, + }, + "response-identity-update": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ReadResourceRequest{ + CurrentState: testCurrentState, + CurrentIdentity: testCurrentIdentity, + IdentitySchema: testIdentitySchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + ReadMethod: func(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var identityData struct { + TestID types.String `tfsdk:"test_id"` + } + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + identityData.TestID = types.StringValue("new-id-123") + + resp.Diagnostics.Append(resp.Identity.Set(ctx, &identityData)...) + }, + }, + }, + }, + expectedResponse: &fwserver.ReadResourceResponse{ + NewState: testCurrentState, + NewIdentity: testNewIdentity, + Private: testEmptyPrivate, + }, + }, + "response-invalid-identity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ReadResourceRequest{ + CurrentState: testCurrentState, + Resource: &testprovider.Resource{ + ReadMethod: func(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // This resource doesn't indicate identity support (via a schema), so this should raise a diagnostic. + resp.Identity = &tfsdk.ResourceIdentity{ + Raw: testNewIdentityValue, + Schema: testIdentitySchema, + } + }, + }, + }, + expectedResponse: &fwserver.ReadResourceResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unexpected Read Response", + "An unexpected error was encountered when creating the read response. New identity data was returned by the provider read 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.", + ), + }, + NewState: testCurrentState, + NewIdentity: testNewIdentity, + Private: testEmptyPrivate, + }, + }, "response-state-removeresource": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, diff --git a/internal/fwserver/server_updateresource.go b/internal/fwserver/server_updateresource.go index ad1d9f99..19827b7e 100644 --- a/internal/fwserver/server_updateresource.go +++ b/internal/fwserver/server_updateresource.go @@ -20,13 +20,15 @@ import ( // UpdateResourceRequest is the framework server request for an update request // with the ApplyResourceChange RPC. type UpdateResourceRequest struct { - Config *tfsdk.Config - PlannedPrivate *privatestate.Data - PlannedState *tfsdk.Plan - PriorState *tfsdk.State - ProviderMeta *tfsdk.Config - ResourceSchema fwschema.Schema - Resource resource.Resource + Config *tfsdk.Config + PlannedPrivate *privatestate.Data + PlannedState *tfsdk.Plan + PlannedIdentity *tfsdk.ResourceIdentity + PriorState *tfsdk.State + ProviderMeta *tfsdk.Config + ResourceSchema fwschema.Schema + IdentitySchema fwschema.Schema + Resource resource.Resource } // UpdateResourceResponse is the framework server response for an update request @@ -34,6 +36,7 @@ type UpdateResourceRequest struct { type UpdateResourceResponse struct { Diagnostics diag.Diagnostics NewState *tfsdk.State + NewIdentity *tfsdk.ResourceIdentity Private *privatestate.Data } @@ -118,12 +121,37 @@ func (s *Server) UpdateResource(ctx context.Context, req *UpdateResourceRequest, resp.Private = req.PlannedPrivate } + // If the resource supports identity and there is no planned identity data, pre-populate with a null value. + // TODO:ResourceIdentity: This logic is likely useless since plan should already handle this, probably should remove. + if req.PlannedIdentity == nil && req.IdentitySchema != nil { + nullIdentityTfValue := tftypes.NewValue(req.IdentitySchema.Type().TerraformType(ctx), nil) + + req.PlannedIdentity = &tfsdk.ResourceIdentity{ + Schema: req.IdentitySchema, + Raw: nullIdentityTfValue.Copy(), + } + } + + // Pre-populate the new identity with the planned identity. + if req.PlannedIdentity != nil { + updateReq.Identity = &tfsdk.ResourceIdentity{ + Schema: req.PlannedIdentity.Schema, + Raw: req.PlannedIdentity.Raw.Copy(), + } + + updateResp.Identity = &tfsdk.ResourceIdentity{ + Schema: req.PlannedIdentity.Schema, + Raw: req.PlannedIdentity.Raw.Copy(), + } + } + logging.FrameworkTrace(ctx, "Calling provider defined Resource Update") req.Resource.Update(ctx, updateReq, &updateResp) logging.FrameworkTrace(ctx, "Called provider defined Resource Update") resp.Diagnostics = updateResp.Diagnostics resp.NewState = &updateResp.State + resp.NewIdentity = updateResp.Identity if !resp.Diagnostics.HasError() && updateResp.State.Raw.Equal(nullSchemaData) { resp.Diagnostics.AddError( @@ -145,6 +173,16 @@ func (s *Server) UpdateResource(ctx context.Context, req *UpdateResourceRequest, return } + if resp.NewIdentity != nil && req.IdentitySchema == nil { + resp.Diagnostics.AddError( + "Unexpected Update Response", + "An unexpected error was encountered when creating the apply response. New identity data was returned by the provider update 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 + } + semanticEqualityReq := SchemaSemanticEqualityRequest{ PriorData: fwschemadata.Data{ Description: fwschemadata.DataDescriptionPlan, diff --git a/internal/fwserver/server_updateresource_test.go b/internal/fwserver/server_updateresource_test.go index 4396e93f..f1761670 100644 --- a/internal/fwserver/server_updateresource_test.go +++ b/internal/fwserver/server_updateresource_test.go @@ -19,6 +19,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "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" "github.com/hashicorp/terraform-plugin-framework/types" @@ -34,6 +35,12 @@ func TestServerUpdateResource(t *testing.T) { }, } + testIdentityType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + testSchemaTypeWriteOnly := tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "test_required": tftypes.String, @@ -52,6 +59,14 @@ func TestServerUpdateResource(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testSchemaWithSemanticEquals := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_computed": schema.StringAttribute{ @@ -101,6 +116,10 @@ func TestServerUpdateResource(t *testing.T) { TestRequired types.String `tfsdk:"test_required"` } + type testIdentitySchemaData struct { + TestID types.String `tfsdk:"test_id"` + } + type testSchemaDataWithSemanticEquals struct { TestComputed types.String `tfsdk:"test_computed"` TestRequired testtypes.StringValueWithSemanticEquals `tfsdk:"test_required"` @@ -274,6 +293,63 @@ func TestServerUpdateResource(t *testing.T) { Private: testEmptyPrivate, }, }, + "request-plannedidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.UpdateResourceRequest{ + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + UpdateMethod: func(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var identityData testIdentitySchemaData + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + if identityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("Unexpected req.Identity Value", "Got: "+identityData.TestID.ValueString()) + } + + // Prevent missing resource state error diagnostic + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + }, + }, + }, + expectedResponse: &fwserver.UpdateResourceResponse{ + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testIdentitySchema, + }, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + Private: testEmptyPrivate, + }, + }, "request-priorstate": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, @@ -699,6 +775,110 @@ func TestServerUpdateResource(t *testing.T) { Private: testEmptyPrivate, }, }, + "response-newidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.UpdateResourceRequest{ + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + IdentitySchema: testIdentitySchema, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + UpdateMethod: func(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.Append(resp.Identity.Set(ctx, testIdentitySchemaData{ + TestID: types.StringValue("new-id-123"), + })...) + + // Prevent missing resource state error diagnostic + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + }, + }, + }, + expectedResponse: &fwserver.UpdateResourceResponse{ + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + }, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + Private: testEmptyPrivate, + }, + }, + "response-invalid-newidentity": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.UpdateResourceRequest{ + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + UpdateMethod: func(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // This resource doesn't indicate identity support (via a schema), so this should raise a diagnostic. + resp.Identity = &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + } + + // Prevent missing resource state error diagnostic + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + }, + }, + }, + expectedResponse: &fwserver.UpdateResourceResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unexpected Update Response", + "An unexpected error was encountered when creating the apply response. New identity data was returned by the provider update 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.", + ), + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testIdentitySchema, + }, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + }), + Schema: testSchema, + }, + Private: testEmptyPrivate, + }, + }, "response-newstate-null": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, diff --git a/internal/proto5server/server_applyresourcechange.go b/internal/proto5server/server_applyresourcechange.go index e4e8bb92..717d071f 100644 --- a/internal/proto5server/server_applyresourcechange.go +++ b/internal/proto5server/server_applyresourcechange.go @@ -36,6 +36,14 @@ func (s *Server) ApplyResourceChange(ctx context.Context, proto5Req *tfprotov5.A return toproto5.ApplyResourceChangeResponse(ctx, fwResp), nil } + identitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, proto5Req.TypeName) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto5.ApplyResourceChangeResponse(ctx, fwResp), nil + } + providerMetaSchema, diags := s.FrameworkServer.ProviderMetaSchema(ctx) fwResp.Diagnostics.Append(diags...) @@ -44,7 +52,7 @@ func (s *Server) ApplyResourceChange(ctx context.Context, proto5Req *tfprotov5.A return toproto5.ApplyResourceChangeResponse(ctx, fwResp), nil } - fwReq, diags := fromproto5.ApplyResourceChangeRequest(ctx, proto5Req, resource, resourceSchema, providerMetaSchema) + fwReq, diags := fromproto5.ApplyResourceChangeRequest(ctx, proto5Req, resource, resourceSchema, providerMetaSchema, identitySchema) fwResp.Diagnostics.Append(diags...) diff --git a/internal/proto5server/server_applyresourcechange_test.go b/internal/proto5server/server_applyresourcechange_test.go index 54484786..6c17d703 100644 --- a/internal/proto5server/server_applyresourcechange_test.go +++ b/internal/proto5server/server_applyresourcechange_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "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" ) @@ -34,6 +35,20 @@ func TestServerApplyResourceChange(t *testing.T) { testEmptyDynamicValue, _ := tfprotov5.NewDynamicValue(testSchemaType, tftypes.NewValue(testSchemaType, nil)) + testIdentityType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testPlannedIdentityValue := testNewDynamicValue(t, testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testNewIdentityDynamicValue := testNewDynamicValue(t, testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }) + testSchema := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_computed": schema.StringAttribute{ @@ -45,6 +60,14 @@ func TestServerApplyResourceChange(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + type testSchemaData struct { TestComputed types.String `tfsdk:"test_computed"` TestRequired types.String `tfsdk:"test_required"` @@ -194,6 +217,79 @@ func TestServerApplyResourceChange(t *testing.T) { }), }, }, + "create-request-plannedidentity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var identityData struct { + TestID types.String `tfsdk:"test_id"` + } + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + if identityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("Unexpected req.Identity", identityData.TestID.ValueString()) + } + + // Prevent missing resource state error diagnostic + var data testSchemaData + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + DeleteMethod: func(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Delete") + }, + UpdateMethod: func(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Update") + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.ApplyResourceChangeRequest{ + Config: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testPlannedIdentityValue, + }, + PriorState: &testEmptyDynamicValue, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov5.ApplyResourceChangeResponse{ + NewState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testPlannedIdentityValue, + }, + }, + }, "create-request-providermeta": { server: &Server{ FrameworkServer: fwserver.Server{ @@ -372,6 +468,73 @@ func TestServerApplyResourceChange(t *testing.T) { }), }, }, + "create-response-newidentity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + identityData := struct { + TestID types.String `tfsdk:"test_id"` + }{ + TestID: types.StringValue("new-id-123"), + } + resp.Diagnostics.Append(resp.Identity.Set(ctx, identityData)...) + + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + DeleteMethod: func(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Delete") + }, + UpdateMethod: func(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Update") + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.ApplyResourceChangeRequest{ + Config: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PriorState: &testEmptyDynamicValue, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov5.ApplyResourceChangeResponse{ + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewIdentityDynamicValue, + }, + NewState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, "create-response-newstate-null": { server: &Server{ FrameworkServer: fwserver.Server{ diff --git a/internal/proto5server/server_planresourcechange.go b/internal/proto5server/server_planresourcechange.go index a5cec198..68918c53 100644 --- a/internal/proto5server/server_planresourcechange.go +++ b/internal/proto5server/server_planresourcechange.go @@ -37,6 +37,14 @@ func (s *Server) PlanResourceChange(ctx context.Context, proto5Req *tfprotov5.Pl return toproto5.PlanResourceChangeResponse(ctx, fwResp), nil } + identitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, proto5Req.TypeName) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto5.PlanResourceChangeResponse(ctx, fwResp), nil + } + providerMetaSchema, diags := s.FrameworkServer.ProviderMetaSchema(ctx) fwResp.Diagnostics.Append(diags...) @@ -53,7 +61,7 @@ func (s *Server) PlanResourceChange(ctx context.Context, proto5Req *tfprotov5.Pl return toproto5.PlanResourceChangeResponse(ctx, fwResp), nil } - fwReq, diags := fromproto5.PlanResourceChangeRequest(ctx, proto5Req, resource, resourceSchema, providerMetaSchema, resourceBehavior) + fwReq, diags := fromproto5.PlanResourceChangeRequest(ctx, proto5Req, resource, resourceSchema, providerMetaSchema, resourceBehavior, identitySchema) fwResp.Diagnostics.Append(diags...) diff --git a/internal/proto5server/server_planresourcechange_test.go b/internal/proto5server/server_planresourcechange_test.go index fc452f6a..dbc9e609 100644 --- a/internal/proto5server/server_planresourcechange_test.go +++ b/internal/proto5server/server_planresourcechange_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "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" ) @@ -33,6 +34,12 @@ func TestServerPlanResourceChange(t *testing.T) { testEmptyDynamicValue, _ := tfprotov5.NewDynamicValue(testSchemaType, tftypes.NewValue(testSchemaType, nil)) + testIdentitySchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + testSchema := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_computed": schema.StringAttribute{ @@ -44,6 +51,18 @@ func TestServerPlanResourceChange(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + type testIdentitySchemaData struct { + TestID types.String `tfsdk:"test_id"` + } + type testSchemaData struct { TestComputed types.String `tfsdk:"test_computed"` TestRequired types.String `tfsdk:"test_required"` @@ -179,6 +198,70 @@ func TestServerPlanResourceChange(t *testing.T) { }), }, }, + "create-request-plannedidentity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentityAndModifyPlan{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + var data testIdentitySchemaData + + resp.Diagnostics.Append(req.Identity.Get(ctx, &data)...) + + if data.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("Unexpected req.Identity", data.TestID.ValueString()) + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanResourceChangeRequest{ + Config: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + ProposedNewState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + PriorState: &testEmptyDynamicValue, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, "create-request-providermeta": { server: &Server{ FrameworkServer: fwserver.Server{ @@ -345,6 +428,70 @@ func TestServerPlanResourceChange(t *testing.T) { }), }, }, + "create-response-plannedidentity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentityAndModifyPlan{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + var data testIdentitySchemaData + + resp.Diagnostics.Append(req.Identity.Get(ctx, &data)...) + + data.TestID = types.StringValue("new-id-123") + + resp.Diagnostics.Append(resp.Identity.Set(ctx, &data)...) + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanResourceChangeRequest{ + Config: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + ProposedNewState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PriorState: &testEmptyDynamicValue, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + }, + }, + }, "create-response-requiresreplace": { server: &Server{ FrameworkServer: fwserver.Server{ diff --git a/internal/proto5server/server_readresource.go b/internal/proto5server/server_readresource.go index e9863e14..299b6783 100644 --- a/internal/proto5server/server_readresource.go +++ b/internal/proto5server/server_readresource.go @@ -37,6 +37,14 @@ func (s *Server) ReadResource(ctx context.Context, proto5Req *tfprotov5.ReadReso return toproto5.ReadResourceResponse(ctx, fwResp), nil } + identitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, proto5Req.TypeName) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto5.ReadResourceResponse(ctx, fwResp), nil + } + providerMetaSchema, diags := s.FrameworkServer.ProviderMetaSchema(ctx) fwResp.Diagnostics.Append(diags...) @@ -45,7 +53,7 @@ func (s *Server) ReadResource(ctx context.Context, proto5Req *tfprotov5.ReadReso return toproto5.ReadResourceResponse(ctx, fwResp), nil } - fwReq, diags := fromproto5.ReadResourceRequest(ctx, proto5Req, resource, resourceSchema, providerMetaSchema) + fwReq, diags := fromproto5.ReadResourceRequest(ctx, proto5Req, resource, resourceSchema, providerMetaSchema, identitySchema) fwResp.Diagnostics.Append(diags...) diff --git a/internal/proto5server/server_readresource_test.go b/internal/proto5server/server_readresource_test.go index 8fbba464..9617d7b7 100644 --- a/internal/proto5server/server_readresource_test.go +++ b/internal/proto5server/server_readresource_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "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" ) @@ -46,6 +47,20 @@ func TestServerReadResource(t *testing.T) { testNewStateRemovedDynamicValue, _ := tfprotov5.NewDynamicValue(testType, tftypes.NewValue(testType, nil)) + testIdentityType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testCurrentIdentityValue := testNewDynamicValue(t, testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testNewIdentityDynamicValue := testNewDynamicValue(t, testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }) + testProviderMetaDynamicValue := testNewDynamicValue(t, tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ @@ -70,6 +85,14 @@ func TestServerReadResource(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testCases := map[string]struct { server *Server request *tfprotov5.ReadResourceRequest @@ -246,6 +269,69 @@ func TestServerReadResource(t *testing.T) { }), }, }, + "request-currentidentity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.ProviderWithMetaSchema{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {}, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + ReadMethod: func(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var identityData struct { + TestID types.String `tfsdk:"test_id"` + } + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + if identityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("Unexpected req.Identity", identityData.TestID.ValueString()) + } + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + } + }, + } + }, + }, + MetaSchemaMethod: func(_ context.Context, _ provider.MetaSchemaRequest, resp *provider.MetaSchemaResponse) { + resp.Schema = metaschema.Schema{ + Attributes: map[string]metaschema.Attribute{ + "test_optional": metaschema.StringAttribute{ + Optional: true, + }, + "test_required": metaschema.StringAttribute{ + Required: true, + }, + }, + } + }, + }, + }, + }, + request: &tfprotov5.ReadResourceRequest{ + CurrentState: testEmptyDynamicValue, + CurrentIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testCurrentIdentityValue, + }, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov5.ReadResourceResponse{ + NewState: testEmptyDynamicValue, + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testCurrentIdentityValue, + }, + }, + }, "response-diagnostics": { server: &Server{ FrameworkServer: fwserver.Server{ @@ -332,6 +418,55 @@ func TestServerReadResource(t *testing.T) { NewState: testNewStateDynamicValue, }, }, + "response-identity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {}, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + ReadMethod: func(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var identityData struct { + TestID types.String `tfsdk:"test_id"` + } + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + identityData.TestID = types.StringValue("new-id-123") + + resp.Diagnostics.Append(resp.Identity.Set(ctx, identityData)...) + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.ReadResourceRequest{ + CurrentState: testEmptyDynamicValue, + CurrentIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testCurrentIdentityValue, + }, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov5.ReadResourceResponse{ + NewState: testEmptyDynamicValue, + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewIdentityDynamicValue, + }, + }, + }, "response-state-removeresource": { server: &Server{ FrameworkServer: fwserver.Server{ diff --git a/internal/proto6server/server_applyresourcechange.go b/internal/proto6server/server_applyresourcechange.go index 0762368b..85fc2dc1 100644 --- a/internal/proto6server/server_applyresourcechange.go +++ b/internal/proto6server/server_applyresourcechange.go @@ -36,6 +36,14 @@ func (s *Server) ApplyResourceChange(ctx context.Context, proto6Req *tfprotov6.A return toproto6.ApplyResourceChangeResponse(ctx, fwResp), nil } + identitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, proto6Req.TypeName) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto6.ApplyResourceChangeResponse(ctx, fwResp), nil + } + providerMetaSchema, diags := s.FrameworkServer.ProviderMetaSchema(ctx) fwResp.Diagnostics.Append(diags...) @@ -44,7 +52,7 @@ func (s *Server) ApplyResourceChange(ctx context.Context, proto6Req *tfprotov6.A return toproto6.ApplyResourceChangeResponse(ctx, fwResp), nil } - fwReq, diags := fromproto6.ApplyResourceChangeRequest(ctx, proto6Req, resource, resourceSchema, providerMetaSchema) + fwReq, diags := fromproto6.ApplyResourceChangeRequest(ctx, proto6Req, resource, resourceSchema, providerMetaSchema, identitySchema) fwResp.Diagnostics.Append(diags...) diff --git a/internal/proto6server/server_applyresourcechange_test.go b/internal/proto6server/server_applyresourcechange_test.go index 66c12933..93b4f40e 100644 --- a/internal/proto6server/server_applyresourcechange_test.go +++ b/internal/proto6server/server_applyresourcechange_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "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" ) @@ -34,6 +35,20 @@ func TestServerApplyResourceChange(t *testing.T) { testEmptyDynamicValue, _ := tfprotov6.NewDynamicValue(testSchemaType, tftypes.NewValue(testSchemaType, nil)) + testIdentityType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testPlannedIdentityValue := testNewDynamicValue(t, testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testNewIdentityDynamicValue := testNewDynamicValue(t, testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }) + testSchema := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_computed": schema.StringAttribute{ @@ -45,6 +60,14 @@ func TestServerApplyResourceChange(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + type testSchemaData struct { TestComputed types.String `tfsdk:"test_computed"` TestRequired types.String `tfsdk:"test_required"` @@ -194,6 +217,79 @@ func TestServerApplyResourceChange(t *testing.T) { }), }, }, + "create-request-plannedidentity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var identityData struct { + TestID types.String `tfsdk:"test_id"` + } + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + if identityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("Unexpected req.Identity", identityData.TestID.ValueString()) + } + + // Prevent missing resource state error diagnostic + var data testSchemaData + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + DeleteMethod: func(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Delete") + }, + UpdateMethod: func(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Update") + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.ApplyResourceChangeRequest{ + Config: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testPlannedIdentityValue, + }, + PriorState: &testEmptyDynamicValue, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov6.ApplyResourceChangeResponse{ + NewState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + NewIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testPlannedIdentityValue, + }, + }, + }, "create-request-providermeta": { server: &Server{ FrameworkServer: fwserver.Server{ @@ -372,6 +468,73 @@ func TestServerApplyResourceChange(t *testing.T) { }), }, }, + "create-response-newidentity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + CreateMethod: func(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + identityData := struct { + TestID types.String `tfsdk:"test_id"` + }{ + TestID: types.StringValue("new-id-123"), + } + resp.Diagnostics.Append(resp.Identity.Set(ctx, identityData)...) + + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + }, + DeleteMethod: func(_ context.Context, _ resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Delete") + }, + UpdateMethod: func(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError("Unexpected Method Call", "Expected: Create, Got: Update") + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.ApplyResourceChangeRequest{ + Config: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PriorState: &testEmptyDynamicValue, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov6.ApplyResourceChangeResponse{ + NewIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewIdentityDynamicValue, + }, + NewState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, "create-response-newstate-null": { server: &Server{ FrameworkServer: fwserver.Server{ diff --git a/internal/proto6server/server_planresourcechange.go b/internal/proto6server/server_planresourcechange.go index 32d13ddd..cdd057e2 100644 --- a/internal/proto6server/server_planresourcechange.go +++ b/internal/proto6server/server_planresourcechange.go @@ -37,6 +37,14 @@ func (s *Server) PlanResourceChange(ctx context.Context, proto6Req *tfprotov6.Pl return toproto6.PlanResourceChangeResponse(ctx, fwResp), nil } + identitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, proto6Req.TypeName) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto6.PlanResourceChangeResponse(ctx, fwResp), nil + } + providerMetaSchema, diags := s.FrameworkServer.ProviderMetaSchema(ctx) fwResp.Diagnostics.Append(diags...) @@ -53,7 +61,7 @@ func (s *Server) PlanResourceChange(ctx context.Context, proto6Req *tfprotov6.Pl return toproto6.PlanResourceChangeResponse(ctx, fwResp), nil } - fwReq, diags := fromproto6.PlanResourceChangeRequest(ctx, proto6Req, resource, resourceSchema, providerMetaSchema, resourceBehavior) + fwReq, diags := fromproto6.PlanResourceChangeRequest(ctx, proto6Req, resource, resourceSchema, providerMetaSchema, resourceBehavior, identitySchema) fwResp.Diagnostics.Append(diags...) diff --git a/internal/proto6server/server_planresourcechange_test.go b/internal/proto6server/server_planresourcechange_test.go index 35b39c2e..6cd50604 100644 --- a/internal/proto6server/server_planresourcechange_test.go +++ b/internal/proto6server/server_planresourcechange_test.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "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/tfprotov6" @@ -32,6 +33,12 @@ func TestServerPlanResourceChange(t *testing.T) { testEmptyDynamicValue, _ := tfprotov6.NewDynamicValue(testSchemaType, tftypes.NewValue(testSchemaType, nil)) + testIdentitySchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + testSchema := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_computed": schema.StringAttribute{ @@ -43,6 +50,18 @@ func TestServerPlanResourceChange(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + type testIdentitySchemaData struct { + TestID types.String `tfsdk:"test_id"` + } + type testSchemaData struct { TestComputed types.String `tfsdk:"test_computed"` TestRequired types.String `tfsdk:"test_required"` @@ -178,6 +197,70 @@ func TestServerPlanResourceChange(t *testing.T) { }), }, }, + "create-request-plannedidentity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentityAndModifyPlan{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + var data testIdentitySchemaData + + resp.Diagnostics.Append(req.Identity.Get(ctx, &data)...) + + if data.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("Unexpected req.Identity", data.TestID.ValueString()) + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanResourceChangeRequest{ + Config: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + ProposedNewState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PriorIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + PriorState: &testEmptyDynamicValue, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov6.PlanResourceChangeResponse{ + PlannedState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, "create-request-providermeta": { server: &Server{ FrameworkServer: fwserver.Server{ @@ -344,6 +427,70 @@ func TestServerPlanResourceChange(t *testing.T) { }), }, }, + "create-response-plannedidentity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentityAndModifyPlan{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + var data testIdentitySchemaData + + resp.Diagnostics.Append(req.Identity.Get(ctx, &data)...) + + data.TestID = types.StringValue("new-id-123") + + resp.Diagnostics.Append(resp.Identity.Set(ctx, &data)...) + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanResourceChangeRequest{ + Config: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + ProposedNewState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PriorState: &testEmptyDynamicValue, + PriorIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov6.PlanResourceChangeResponse{ + PlannedState: testNewDynamicValue(t, testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + }, + }, + }, "create-response-requiresreplace": { server: &Server{ FrameworkServer: fwserver.Server{ diff --git a/internal/proto6server/server_readresource.go b/internal/proto6server/server_readresource.go index d5b0cfab..89cec552 100644 --- a/internal/proto6server/server_readresource.go +++ b/internal/proto6server/server_readresource.go @@ -36,6 +36,14 @@ func (s *Server) ReadResource(ctx context.Context, proto6Req *tfprotov6.ReadReso return toproto6.ReadResourceResponse(ctx, fwResp), nil } + identitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, proto6Req.TypeName) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto6.ReadResourceResponse(ctx, fwResp), nil + } + providerMetaSchema, diags := s.FrameworkServer.ProviderMetaSchema(ctx) fwResp.Diagnostics.Append(diags...) @@ -44,7 +52,7 @@ func (s *Server) ReadResource(ctx context.Context, proto6Req *tfprotov6.ReadReso return toproto6.ReadResourceResponse(ctx, fwResp), nil } - fwReq, diags := fromproto6.ReadResourceRequest(ctx, proto6Req, resource, resourceSchema, providerMetaSchema) + fwReq, diags := fromproto6.ReadResourceRequest(ctx, proto6Req, resource, resourceSchema, providerMetaSchema, identitySchema) fwResp.Diagnostics.Append(diags...) diff --git a/internal/proto6server/server_readresource_test.go b/internal/proto6server/server_readresource_test.go index 7e218000..2f096bbe 100644 --- a/internal/proto6server/server_readresource_test.go +++ b/internal/proto6server/server_readresource_test.go @@ -18,6 +18,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "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" ) @@ -46,6 +47,20 @@ func TestServerReadResource(t *testing.T) { testNewStateRemovedDynamicValue, _ := tfprotov6.NewDynamicValue(testType, tftypes.NewValue(testType, nil)) + testIdentityType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testCurrentIdentityValue := testNewDynamicValue(t, testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testNewIdentityDynamicValue := testNewDynamicValue(t, testIdentityType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }) + testProviderMetaDynamicValue := testNewDynamicValue(t, tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ @@ -70,6 +85,14 @@ func TestServerReadResource(t *testing.T) { }, } + testIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testCases := map[string]struct { server *Server request *tfprotov6.ReadResourceRequest @@ -246,6 +269,69 @@ func TestServerReadResource(t *testing.T) { }), }, }, + "request-currentidentity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.ProviderWithMetaSchema{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {}, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + ReadMethod: func(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var identityData struct { + TestID types.String `tfsdk:"test_id"` + } + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + if identityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError("Unexpected req.Identity", identityData.TestID.ValueString()) + } + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + } + }, + } + }, + }, + MetaSchemaMethod: func(_ context.Context, _ provider.MetaSchemaRequest, resp *provider.MetaSchemaResponse) { + resp.Schema = metaschema.Schema{ + Attributes: map[string]metaschema.Attribute{ + "test_optional": metaschema.StringAttribute{ + Optional: true, + }, + "test_required": metaschema.StringAttribute{ + Required: true, + }, + }, + } + }, + }, + }, + }, + request: &tfprotov6.ReadResourceRequest{ + CurrentState: testEmptyDynamicValue, + CurrentIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testCurrentIdentityValue, + }, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov6.ReadResourceResponse{ + NewState: testEmptyDynamicValue, + NewIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testCurrentIdentityValue, + }, + }, + }, "response-diagnostics": { server: &Server{ FrameworkServer: fwserver.Server{ @@ -332,6 +418,55 @@ func TestServerReadResource(t *testing.T) { NewState: testNewStateDynamicValue, }, }, + "response-identity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ResourcesMethod: func(_ context.Context) []func() resource.Resource { + return []func() resource.Resource{ + func() resource.Resource { + return &testprovider.ResourceWithIdentity{ + Resource: &testprovider.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {}, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_resource" + }, + ReadMethod: func(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var identityData struct { + TestID types.String `tfsdk:"test_id"` + } + + resp.Diagnostics.Append(req.Identity.Get(ctx, &identityData)...) + + identityData.TestID = types.StringValue("new-id-123") + + resp.Diagnostics.Append(resp.Identity.Set(ctx, identityData)...) + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testIdentitySchema + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.ReadResourceRequest{ + CurrentState: testEmptyDynamicValue, + CurrentIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testCurrentIdentityValue, + }, + TypeName: "test_resource", + }, + expectedResponse: &tfprotov6.ReadResourceResponse{ + NewState: testEmptyDynamicValue, + NewIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewIdentityDynamicValue, + }, + }, + }, "response-state-removeresource": { server: &Server{ FrameworkServer: fwserver.Server{ diff --git a/internal/testing/testprovider/resourcewithidentityandmodifyplan.go b/internal/testing/testprovider/resourcewithidentityandmodifyplan.go new file mode 100644 index 00000000..5a1dc884 --- /dev/null +++ b/internal/testing/testprovider/resourcewithidentityandmodifyplan.go @@ -0,0 +1,43 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource" +) + +var _ resource.Resource = &ResourceWithIdentityAndModifyPlan{} +var _ resource.ResourceWithIdentity = &ResourceWithIdentityAndModifyPlan{} +var _ resource.ResourceWithModifyPlan = &ResourceWithIdentityAndModifyPlan{} + +// Declarative resource.ResourceWithIdentityAndModifyPlan for unit testing. +type ResourceWithIdentityAndModifyPlan struct { + *Resource + + // ResourceWithIdentity interface methods + IdentitySchemaMethod func(context.Context, resource.IdentitySchemaRequest, *resource.IdentitySchemaResponse) + + // ResourceWithModifyPlan interface methods + ModifyPlanMethod func(context.Context, resource.ModifyPlanRequest, *resource.ModifyPlanResponse) +} + +// IdentitySchema implements resource.ResourceWithIdentity. +func (p *ResourceWithIdentityAndModifyPlan) IdentitySchema(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + if p.IdentitySchemaMethod == nil { + return + } + + p.IdentitySchemaMethod(ctx, req, resp) +} + +// ModifyPlan satisfies the resource.ResourceWithModifyPlan interface. +func (r *ResourceWithIdentityAndModifyPlan) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if r.ModifyPlanMethod == nil { + return + } + + r.ModifyPlanMethod(ctx, req, resp) +} diff --git a/internal/toproto5/applyresourcechange.go b/internal/toproto5/applyresourcechange.go index 28937399..1b5da0df 100644 --- a/internal/toproto5/applyresourcechange.go +++ b/internal/toproto5/applyresourcechange.go @@ -27,6 +27,11 @@ func ApplyResourceChangeResponse(ctx context.Context, fw *fwserver.ApplyResource proto5.Diagnostics = append(proto5.Diagnostics, Diagnostics(ctx, diags)...) proto5.NewState = newState + newIdentity, diags := ResourceIdentity(ctx, fw.NewIdentity) + + proto5.Diagnostics = append(proto5.Diagnostics, Diagnostics(ctx, diags)...) + proto5.NewIdentity = newIdentity + newPrivate, diags := fw.Private.Bytes(ctx) proto5.Diagnostics = append(proto5.Diagnostics, Diagnostics(ctx, diags)...) diff --git a/internal/toproto5/applyresourcechange_test.go b/internal/toproto5/applyresourcechange_test.go index 85f8c99d..63911e17 100644 --- a/internal/toproto5/applyresourcechange_test.go +++ b/internal/toproto5/applyresourcechange_test.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) @@ -38,6 +39,22 @@ func TestApplyResourceChangeResponse(t *testing.T) { t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) } + testIdentityProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testIdentityProto5Value := tftypes.NewValue(testIdentityProto5Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testIdentityProto5DynamicValue, err := tfprotov5.NewDynamicValue(testIdentityProto5Type, testIdentityProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + testState := &tfsdk.State{ Raw: testProto5Value, Schema: schema.Schema{ @@ -60,6 +77,28 @@ func TestApplyResourceChangeResponse(t *testing.T) { }, } + testIdentity := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto5Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + }, + } + + testIdentityInvalid := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto5Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.BoolAttribute{ + RequiredForImport: true, + }, + }, + }, + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -131,6 +170,37 @@ func TestApplyResourceChangeResponse(t *testing.T) { }, }, }, + "diagnostics-invalid-newidentity": { + input: &fwserver.ApplyResourceChangeResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + NewIdentity: testIdentityInvalid, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "test warning summary", + Detail: "test warning details", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "test error summary", + Detail: "test error details", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Unable to Convert Resource Identity", + Detail: "An unexpected error was encountered when converting the resource identity to the protocol type. " + + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n" + + "Please report this to the provider developer:\n\n" + + "Unable to create DynamicValue: AttributeName(\"test_id\"): unexpected value type string, tftypes.Bool values must be of type bool", + }, + }, + }, + }, "newstate": { input: &fwserver.ApplyResourceChangeResponse{ NewState: testState, @@ -139,6 +209,16 @@ func TestApplyResourceChangeResponse(t *testing.T) { NewState: &testProto5DynamicValue, }, }, + "newidentity": { + input: &fwserver.ApplyResourceChangeResponse{ + NewIdentity: testIdentity, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testIdentityProto5DynamicValue, + }, + }, + }, "private": { input: &fwserver.ApplyResourceChangeResponse{ Private: &privatestate.Data{ diff --git a/internal/toproto5/planresourcechange.go b/internal/toproto5/planresourcechange.go index f3292a96..23938df2 100644 --- a/internal/toproto5/planresourcechange.go +++ b/internal/toproto5/planresourcechange.go @@ -29,6 +29,11 @@ func PlanResourceChangeResponse(ctx context.Context, fw *fwserver.PlanResourceCh proto5.Diagnostics = append(proto5.Diagnostics, Diagnostics(ctx, diags)...) proto5.PlannedState = plannedState + plannedIdentity, diags := ResourceIdentity(ctx, fw.PlannedIdentity) + + proto5.Diagnostics = append(proto5.Diagnostics, Diagnostics(ctx, diags)...) + proto5.PlannedIdentity = plannedIdentity + requiresReplace, diags := totftypes.AttributePaths(ctx, fw.RequiresReplace) proto5.Diagnostics = append(proto5.Diagnostics, Diagnostics(ctx, diags)...) diff --git a/internal/toproto5/planresourcechange_test.go b/internal/toproto5/planresourcechange_test.go index 05924b5b..9506ba51 100644 --- a/internal/toproto5/planresourcechange_test.go +++ b/internal/toproto5/planresourcechange_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" "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/tfsdk" ) @@ -40,6 +41,22 @@ func TestPlanResourceChangeResponse(t *testing.T) { t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) } + testIdentityProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testIdentityProto5Value := tftypes.NewValue(testIdentityProto5Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testIdentityProto5DynamicValue, err := tfprotov5.NewDynamicValue(testIdentityProto5Type, testIdentityProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + testState := &tfsdk.State{ Raw: testProto5Value, Schema: schema.Schema{ @@ -62,6 +79,28 @@ func TestPlanResourceChangeResponse(t *testing.T) { }, } + testIdentity := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto5Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + }, + } + + testIdentityInvalid := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto5Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.BoolAttribute{ + RequiredForImport: true, + }, + }, + }, + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -143,6 +182,37 @@ func TestPlanResourceChangeResponse(t *testing.T) { }, }, }, + "diagnostics-invalid-plannedidentity": { + input: &fwserver.PlanResourceChangeResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + PlannedIdentity: testIdentityInvalid, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "test warning summary", + Detail: "test warning details", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "test error summary", + Detail: "test error details", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Unable to Convert Resource Identity", + Detail: "An unexpected error was encountered when converting the resource identity to the protocol type. " + + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n" + + "Please report this to the provider developer:\n\n" + + "Unable to create DynamicValue: AttributeName(\"test_id\"): unexpected value type string, tftypes.Bool values must be of type bool", + }, + }, + }, + }, "plannedprivate-empty": { input: &fwserver.PlanResourceChangeResponse{ PlannedPrivate: &privatestate.Data{ @@ -177,6 +247,16 @@ func TestPlanResourceChangeResponse(t *testing.T) { PlannedState: &testProto5DynamicValue, }, }, + "plannedidentity": { + input: &fwserver.PlanResourceChangeResponse{ + PlannedIdentity: testIdentity, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testIdentityProto5DynamicValue, + }, + }, + }, "requiresreplace": { input: &fwserver.PlanResourceChangeResponse{ RequiresReplace: path.Paths{ diff --git a/internal/toproto5/readresource.go b/internal/toproto5/readresource.go index 9193a395..56695ada 100644 --- a/internal/toproto5/readresource.go +++ b/internal/toproto5/readresource.go @@ -28,6 +28,11 @@ func ReadResourceResponse(ctx context.Context, fw *fwserver.ReadResourceResponse proto5.Diagnostics = append(proto5.Diagnostics, Diagnostics(ctx, diags)...) proto5.NewState = newState + newIdentity, diags := ResourceIdentity(ctx, fw.NewIdentity) + + proto5.Diagnostics = append(proto5.Diagnostics, Diagnostics(ctx, diags)...) + proto5.NewIdentity = newIdentity + newPrivate, diags := fw.Private.Bytes(ctx) proto5.Diagnostics = append(proto5.Diagnostics, Diagnostics(ctx, diags)...) diff --git a/internal/toproto5/readresource_test.go b/internal/toproto5/readresource_test.go index 2e9c549d..8058f1c3 100644 --- a/internal/toproto5/readresource_test.go +++ b/internal/toproto5/readresource_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" "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" ) @@ -39,6 +40,22 @@ func TestReadResourceResponse(t *testing.T) { t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) } + testIdentityProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testIdentityProto5Value := tftypes.NewValue(testIdentityProto5Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testIdentityProto5DynamicValue, err := tfprotov5.NewDynamicValue(testIdentityProto5Type, testIdentityProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -69,6 +86,28 @@ func TestReadResourceResponse(t *testing.T) { }, } + testIdentity := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto5Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + }, + } + + testIdentityInvalid := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto5Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.BoolAttribute{ + RequiredForImport: true, + }, + }, + }, + } + testDeferral := &resource.Deferred{ Reason: resource.DeferredReasonAbsentPrereq, } @@ -142,6 +181,37 @@ func TestReadResourceResponse(t *testing.T) { }, }, }, + "diagnostics-invalid-newidentity": { + input: &fwserver.ReadResourceResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + NewIdentity: testIdentityInvalid, + }, + expected: &tfprotov5.ReadResourceResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "test warning summary", + Detail: "test warning details", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "test error summary", + Detail: "test error details", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Unable to Convert Resource Identity", + Detail: "An unexpected error was encountered when converting the resource identity to the protocol type. " + + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n" + + "Please report this to the provider developer:\n\n" + + "Unable to create DynamicValue: AttributeName(\"test_id\"): unexpected value type string, tftypes.Bool values must be of type bool", + }, + }, + }, + }, "newstate": { input: &fwserver.ReadResourceResponse{ NewState: testState, @@ -150,6 +220,16 @@ func TestReadResourceResponse(t *testing.T) { NewState: &testProto5DynamicValue, }, }, + "newidentity": { + input: &fwserver.ReadResourceResponse{ + NewIdentity: testIdentity, + }, + expected: &tfprotov5.ReadResourceResponse{ + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testIdentityProto5DynamicValue, + }, + }, + }, "private-empty": { input: &fwserver.ReadResourceResponse{ Private: &privatestate.Data{ diff --git a/internal/toproto5/resource_identity.go b/internal/toproto5/resource_identity.go new file mode 100644 index 00000000..eea046ac --- /dev/null +++ b/internal/toproto5/resource_identity.go @@ -0,0 +1,35 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto5 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// ResourceIdentity returns the *tfprotov5.ResourceIdentityData for a *tfsdk.ResourceIdentity. +func ResourceIdentity(ctx context.Context, fw *tfsdk.ResourceIdentity) (*tfprotov5.ResourceIdentityData, diag.Diagnostics) { + if fw == nil { + return nil, nil + } + + identitySchemaData := &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionResourceIdentity, + Schema: fw.Schema, + TerraformValue: fw.Raw, + } + + identityData, diags := DynamicValue(ctx, identitySchemaData) + if diags.HasError() { + return nil, diags + } + + return &tfprotov5.ResourceIdentityData{ + IdentityData: identityData, + }, nil +} diff --git a/internal/toproto5/resource_identity_test.go b/internal/toproto5/resource_identity_test.go new file mode 100644 index 00000000..ff62eff6 --- /dev/null +++ b/internal/toproto5/resource_identity_test.go @@ -0,0 +1,109 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto5_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestResourceIdentity(t *testing.T) { + t.Parallel() + + testProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute": tftypes.String, + }, + } + + testProto5Value := tftypes.NewValue(testProto5Type, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }) + + testProto5DynamicValue, err := tfprotov5.NewDynamicValue(testProto5Type, testProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testResourceIdentity := &tfsdk.ResourceIdentity{ + Raw: testProto5Value, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.StringType, + }, + }, + }, + } + + testResourceIdentityInvalid := &tfsdk.ResourceIdentity{ + Raw: testProto5Value, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.BoolType, + }, + }, + }, + } + + testCases := map[string]struct { + input *tfsdk.ResourceIdentity + expected *tfprotov5.ResourceIdentityData + expectedDiagnostics diag.Diagnostics + }{ + "nil": { + input: nil, + expected: nil, + }, + "invalid-schema": { + input: testResourceIdentityInvalid, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Resource Identity", + "An unexpected error was encountered when converting the resource identity to the protocol type. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Unable to create DynamicValue: AttributeName(\"test_attribute\"): unexpected value type string, tftypes.Bool values must be of type bool", + ), + }, + }, + "valid": { + input: testResourceIdentity, + expected: &tfprotov5.ResourceIdentityData{ + IdentityData: &testProto5DynamicValue, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := toproto5.ResourceIdentity(context.Background(), testCase.input) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/internal/toproto6/applyresourcechange.go b/internal/toproto6/applyresourcechange.go index c3d158f0..d47230d4 100644 --- a/internal/toproto6/applyresourcechange.go +++ b/internal/toproto6/applyresourcechange.go @@ -27,6 +27,11 @@ func ApplyResourceChangeResponse(ctx context.Context, fw *fwserver.ApplyResource proto6.Diagnostics = append(proto6.Diagnostics, Diagnostics(ctx, diags)...) proto6.NewState = newState + newIdentity, diags := ResourceIdentity(ctx, fw.NewIdentity) + + proto6.Diagnostics = append(proto6.Diagnostics, Diagnostics(ctx, diags)...) + proto6.NewIdentity = newIdentity + newPrivate, diags := fw.Private.Bytes(ctx) proto6.Diagnostics = append(proto6.Diagnostics, Diagnostics(ctx, diags)...) diff --git a/internal/toproto6/applyresourcechange_test.go b/internal/toproto6/applyresourcechange_test.go index 5084841d..19a9f614 100644 --- a/internal/toproto6/applyresourcechange_test.go +++ b/internal/toproto6/applyresourcechange_test.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) @@ -38,6 +39,22 @@ func TestApplyResourceChangeResponse(t *testing.T) { t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) } + testIdentityProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testIdentityProto6Value := tftypes.NewValue(testIdentityProto6Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testIdentityProto6DynamicValue, err := tfprotov6.NewDynamicValue(testIdentityProto6Type, testIdentityProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + testState := &tfsdk.State{ Raw: testProto6Value, Schema: schema.Schema{ @@ -60,6 +77,28 @@ func TestApplyResourceChangeResponse(t *testing.T) { }, } + testIdentity := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto6Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + }, + } + + testIdentityInvalid := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto6Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.BoolAttribute{ + RequiredForImport: true, + }, + }, + }, + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -131,6 +170,37 @@ func TestApplyResourceChangeResponse(t *testing.T) { }, }, }, + "diagnostics-invalid-newidentity": { + input: &fwserver.ApplyResourceChangeResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + NewIdentity: testIdentityInvalid, + }, + expected: &tfprotov6.ApplyResourceChangeResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "test warning summary", + Detail: "test warning details", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "test error summary", + Detail: "test error details", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Convert Resource Identity", + Detail: "An unexpected error was encountered when converting the resource identity to the protocol type. " + + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n" + + "Please report this to the provider developer:\n\n" + + "Unable to create DynamicValue: AttributeName(\"test_id\"): unexpected value type string, tftypes.Bool values must be of type bool", + }, + }, + }, + }, "newstate": { input: &fwserver.ApplyResourceChangeResponse{ NewState: testState, @@ -139,6 +209,16 @@ func TestApplyResourceChangeResponse(t *testing.T) { NewState: &testProto6DynamicValue, }, }, + "newidentity": { + input: &fwserver.ApplyResourceChangeResponse{ + NewIdentity: testIdentity, + }, + expected: &tfprotov6.ApplyResourceChangeResponse{ + NewIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testIdentityProto6DynamicValue, + }, + }, + }, "private": { input: &fwserver.ApplyResourceChangeResponse{ Private: &privatestate.Data{ diff --git a/internal/toproto6/planresourcechange.go b/internal/toproto6/planresourcechange.go index 486f7ab0..cf9b2642 100644 --- a/internal/toproto6/planresourcechange.go +++ b/internal/toproto6/planresourcechange.go @@ -29,6 +29,11 @@ func PlanResourceChangeResponse(ctx context.Context, fw *fwserver.PlanResourceCh proto6.Diagnostics = append(proto6.Diagnostics, Diagnostics(ctx, diags)...) proto6.PlannedState = plannedState + plannedIdentity, diags := ResourceIdentity(ctx, fw.PlannedIdentity) + + proto6.Diagnostics = append(proto6.Diagnostics, Diagnostics(ctx, diags)...) + proto6.PlannedIdentity = plannedIdentity + requiresReplace, diags := totftypes.AttributePaths(ctx, fw.RequiresReplace) proto6.Diagnostics = append(proto6.Diagnostics, Diagnostics(ctx, diags)...) diff --git a/internal/toproto6/planresourcechange_test.go b/internal/toproto6/planresourcechange_test.go index 376ec1f9..344ac0a2 100644 --- a/internal/toproto6/planresourcechange_test.go +++ b/internal/toproto6/planresourcechange_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" "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/tfsdk" ) @@ -40,6 +41,22 @@ func TestPlanResourceChangeResponse(t *testing.T) { t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) } + testIdentityProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testIdentityProto6Value := tftypes.NewValue(testIdentityProto6Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testIdentityProto6DynamicValue, err := tfprotov6.NewDynamicValue(testIdentityProto6Type, testIdentityProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + testState := &tfsdk.State{ Raw: testProto6Value, Schema: schema.Schema{ @@ -62,6 +79,28 @@ func TestPlanResourceChangeResponse(t *testing.T) { }, } + testIdentity := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto6Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + }, + } + + testIdentityInvalid := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto6Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.BoolAttribute{ + RequiredForImport: true, + }, + }, + }, + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -143,6 +182,37 @@ func TestPlanResourceChangeResponse(t *testing.T) { }, }, }, + "diagnostics-invalid-plannedidentity": { + input: &fwserver.PlanResourceChangeResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + PlannedIdentity: testIdentityInvalid, + }, + expected: &tfprotov6.PlanResourceChangeResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "test warning summary", + Detail: "test warning details", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "test error summary", + Detail: "test error details", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Convert Resource Identity", + Detail: "An unexpected error was encountered when converting the resource identity to the protocol type. " + + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n" + + "Please report this to the provider developer:\n\n" + + "Unable to create DynamicValue: AttributeName(\"test_id\"): unexpected value type string, tftypes.Bool values must be of type bool", + }, + }, + }, + }, "plannedprivate-empty": { input: &fwserver.PlanResourceChangeResponse{ PlannedPrivate: &privatestate.Data{ @@ -177,6 +247,16 @@ func TestPlanResourceChangeResponse(t *testing.T) { PlannedState: &testProto6DynamicValue, }, }, + "plannedidentity": { + input: &fwserver.PlanResourceChangeResponse{ + PlannedIdentity: testIdentity, + }, + expected: &tfprotov6.PlanResourceChangeResponse{ + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testIdentityProto6DynamicValue, + }, + }, + }, "requiresreplace": { input: &fwserver.PlanResourceChangeResponse{ RequiresReplace: path.Paths{ diff --git a/internal/toproto6/readresource.go b/internal/toproto6/readresource.go index 2de7adf8..ea60e5a0 100644 --- a/internal/toproto6/readresource.go +++ b/internal/toproto6/readresource.go @@ -28,6 +28,11 @@ func ReadResourceResponse(ctx context.Context, fw *fwserver.ReadResourceResponse proto6.Diagnostics = append(proto6.Diagnostics, Diagnostics(ctx, diags)...) proto6.NewState = newState + newIdentity, diags := ResourceIdentity(ctx, fw.NewIdentity) + + proto6.Diagnostics = append(proto6.Diagnostics, Diagnostics(ctx, diags)...) + proto6.NewIdentity = newIdentity + newPrivate, diags := fw.Private.Bytes(ctx) proto6.Diagnostics = append(proto6.Diagnostics, Diagnostics(ctx, diags)...) diff --git a/internal/toproto6/readresource_test.go b/internal/toproto6/readresource_test.go index 18842699..0596dc30 100644 --- a/internal/toproto6/readresource_test.go +++ b/internal/toproto6/readresource_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" "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" ) @@ -39,6 +40,22 @@ func TestReadResourceResponse(t *testing.T) { t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) } + testIdentityProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testIdentityProto6Value := tftypes.NewValue(testIdentityProto6Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testIdentityProto6DynamicValue, err := tfprotov6.NewDynamicValue(testIdentityProto6Type, testIdentityProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + testState := &tfsdk.State{ Raw: testProto6Value, Schema: schema.Schema{ @@ -61,6 +78,28 @@ func TestReadResourceResponse(t *testing.T) { }, } + testIdentity := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto6Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + }, + } + + testIdentityInvalid := &tfsdk.ResourceIdentity{ + Raw: testIdentityProto6Value, + Schema: identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.BoolAttribute{ + RequiredForImport: true, + }, + }, + }, + } + testProviderKeyValue := privatestate.MustMarshalToJson(map[string][]byte{ "providerKeyOne": []byte(`{"pKeyOne": {"k0": "zero", "k1": 1}}`), }) @@ -142,6 +181,37 @@ func TestReadResourceResponse(t *testing.T) { }, }, }, + "diagnostics-invalid-newidentity": { + input: &fwserver.ReadResourceResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + NewIdentity: testIdentityInvalid, + }, + expected: &tfprotov6.ReadResourceResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "test warning summary", + Detail: "test warning details", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "test error summary", + Detail: "test error details", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Convert Resource Identity", + Detail: "An unexpected error was encountered when converting the resource identity to the protocol type. " + + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n" + + "Please report this to the provider developer:\n\n" + + "Unable to create DynamicValue: AttributeName(\"test_id\"): unexpected value type string, tftypes.Bool values must be of type bool", + }, + }, + }, + }, "newstate": { input: &fwserver.ReadResourceResponse{ NewState: testState, @@ -150,6 +220,16 @@ func TestReadResourceResponse(t *testing.T) { NewState: &testProto6DynamicValue, }, }, + "newidentity": { + input: &fwserver.ReadResourceResponse{ + NewIdentity: testIdentity, + }, + expected: &tfprotov6.ReadResourceResponse{ + NewIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testIdentityProto6DynamicValue, + }, + }, + }, "private-empty": { input: &fwserver.ReadResourceResponse{ Private: &privatestate.Data{ diff --git a/internal/toproto6/resource_identity.go b/internal/toproto6/resource_identity.go new file mode 100644 index 00000000..3dac3de9 --- /dev/null +++ b/internal/toproto6/resource_identity.go @@ -0,0 +1,35 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto6 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// ResourceIdentity returns the *tfprotov6.ResourceIdentityData for a *tfsdk.ResourceIdentity. +func ResourceIdentity(ctx context.Context, fw *tfsdk.ResourceIdentity) (*tfprotov6.ResourceIdentityData, diag.Diagnostics) { + if fw == nil { + return nil, nil + } + + identitySchemaData := &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionResourceIdentity, + Schema: fw.Schema, + TerraformValue: fw.Raw, + } + + identityData, diags := DynamicValue(ctx, identitySchemaData) + if diags.HasError() { + return nil, diags + } + + return &tfprotov6.ResourceIdentityData{ + IdentityData: identityData, + }, nil +} diff --git a/internal/toproto6/resource_identity_test.go b/internal/toproto6/resource_identity_test.go new file mode 100644 index 00000000..699e29b6 --- /dev/null +++ b/internal/toproto6/resource_identity_test.go @@ -0,0 +1,109 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto6_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestResourceIdentity(t *testing.T) { + t.Parallel() + + testProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute": tftypes.String, + }, + } + + testProto6Value := tftypes.NewValue(testProto6Type, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }) + + testProto6DynamicValue, err := tfprotov6.NewDynamicValue(testProto6Type, testProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testResourceIdentity := &tfsdk.ResourceIdentity{ + Raw: testProto6Value, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.StringType, + }, + }, + }, + } + + testResourceIdentityInvalid := &tfsdk.ResourceIdentity{ + Raw: testProto6Value, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.BoolType, + }, + }, + }, + } + + testCases := map[string]struct { + input *tfsdk.ResourceIdentity + expected *tfprotov6.ResourceIdentityData + expectedDiagnostics diag.Diagnostics + }{ + "nil": { + input: nil, + expected: nil, + }, + "invalid-schema": { + input: testResourceIdentityInvalid, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Resource Identity", + "An unexpected error was encountered when converting the resource identity to the protocol type. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Unable to create DynamicValue: AttributeName(\"test_attribute\"): unexpected value type string, tftypes.Bool values must be of type bool", + ), + }, + }, + "valid": { + input: testResourceIdentity, + expected: &tfprotov6.ResourceIdentityData{ + IdentityData: &testProto6DynamicValue, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := toproto6.ResourceIdentity(context.Background(), testCase.input) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/resource/create.go b/resource/create.go index 8831f837..920cc5dd 100644 --- a/resource/create.go +++ b/resource/create.go @@ -23,6 +23,10 @@ type CreateRequest struct { // Plan is the planned state for the resource. Plan tfsdk.Plan + // Identity is the planned identity for the resource. If the resource does not + // support identity, this value will not be set. + Identity *tfsdk.ResourceIdentity + // ProviderMeta is metadata from the provider_meta block of the module. ProviderMeta tfsdk.Config } @@ -37,6 +41,14 @@ type CreateResponse struct { // should be set during the resource's Create operation. State tfsdk.State + // Identity is the identity of the resource following the Create operation. + // This field is pre-populated from CreateRequest.Identity and + // should be set during the resource's Create operation. + // + // If the resource does not support identity, this value will not be set and will + // raise a diagnostic if set by the resource's Create operation. + Identity *tfsdk.ResourceIdentity + // Private is the private state resource data following the Create operation. // This field is not pre-populated as there is no pre-existing private state // data during the resource's Create operation. diff --git a/resource/delete.go b/resource/delete.go index ab81a6c9..8281dffa 100644 --- a/resource/delete.go +++ b/resource/delete.go @@ -33,10 +33,13 @@ type DeleteRequest struct { // should set values on the DeleteResponse as appropriate. type DeleteResponse struct { // State is the state of the resource following the Delete operation. - // This field is pre-populated from UpdateResourceRequest.Plan and - // should be set during the resource's Update operation. + // This field is pre-populated from DeleteRequest.State and + // should be set during the resource's Delete operation. State tfsdk.State + // Identity is the identity of the resource following the Delete operation. + Identity *tfsdk.ResourceIdentity + // Private is the private state resource data following the Delete // operation. This field is pre-populated from DeleteRequest.Private and // can be modified during the resource's Delete operation in cases where diff --git a/resource/modify_plan.go b/resource/modify_plan.go index 28843cec..0cc71bea 100644 --- a/resource/modify_plan.go +++ b/resource/modify_plan.go @@ -35,6 +35,10 @@ type ModifyPlanRequest struct { // State is the current state of the resource. State tfsdk.State + // Identity is the current identity of the resource. If the resource does not + // support identity, this value will not be set. + Identity *tfsdk.ResourceIdentity + // Plan is the planned new state for the resource. Terraform 1.3 and later // supports resource destroy planning, in which this will contain a null // value. @@ -65,6 +69,13 @@ type ModifyPlanResponse struct { // Plan is the planned new state for the resource. Plan tfsdk.Plan + // Identity is the planned new identity of the resource. + // This field is pre-populated from ModifyPlanRequest.Identity. + // + // If the resource does not support identity, this value will not be set and will + // raise a diagnostic if set. + Identity *tfsdk.ResourceIdentity + // RequiresReplace is a list of attribute paths that require the // resource to be replaced. They should point to the specific field // that changed that requires the resource to be destroyed and diff --git a/resource/read.go b/resource/read.go index 53c4cb83..74820e33 100644 --- a/resource/read.go +++ b/resource/read.go @@ -30,6 +30,10 @@ type ReadRequest struct { // operation. State tfsdk.State + // Identity is the current identity of the resource prior to the Read + // operation. If the resource does not support identity, this value will not be set. + Identity *tfsdk.ResourceIdentity + // Private is provider-defined resource private state data which was previously // stored with the resource state. This data is opaque to Terraform and does // not affect plan output. Any existing data is copied to @@ -57,6 +61,14 @@ type ReadResponse struct { // should be set during the resource's Read operation. State tfsdk.State + // Identity is the identity of the resource following the Read operation. + // This field is pre-populated from ReadRequest.Identity and + // should be set during the resource's Read operation. + // + // If the resource does not support identity, this value will not be set and will + // raise a diagnostic if set by the resource's Read operation. + Identity *tfsdk.ResourceIdentity + // Private is the private state resource data following the Read operation. // This field is pre-populated from ReadResourceRequest.Private and // can be modified during the resource's Read operation. diff --git a/resource/update.go b/resource/update.go index 0dceaf8a..d832b24c 100644 --- a/resource/update.go +++ b/resource/update.go @@ -27,6 +27,10 @@ type UpdateRequest struct { // operation. State tfsdk.State + // Identity is the planned identity for the resource. If the resource does not + // support identity, this value will not be set. + Identity *tfsdk.ResourceIdentity + // ProviderMeta is metadata from the provider_meta block of the module. ProviderMeta tfsdk.Config @@ -49,6 +53,14 @@ type UpdateResponse struct { // should be set during the resource's Update operation. State tfsdk.State + // Identity is the identity of the resource following the Update operation. + // This field is pre-populated from UpdateRequest.Identity and + // should be set during the resource's Update operation. + // + // If the resource does not support identity, this value will not be set and will + // raise a diagnostic if set by the resource's Update operation. + Identity *tfsdk.ResourceIdentity + // Private is the private state resource data following the Update operation. // This field is pre-populated from UpdateRequest.Private and // can be modified during the resource's Update operation. diff --git a/tfsdk/resource_identity.go b/tfsdk/resource_identity.go new file mode 100644 index 00000000..d02f1e8e --- /dev/null +++ b/tfsdk/resource_identity.go @@ -0,0 +1,91 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfsdk + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// ResourceIdentity represents the identity data for a managed resource. +type ResourceIdentity struct { + Raw tftypes.Value + Schema fwschema.Schema +} + +// Get populates the struct passed as `target` with the entire identity. +func (s ResourceIdentity) Get(ctx context.Context, target interface{}) diag.Diagnostics { + return s.data().Get(ctx, target) +} + +// GetAttribute retrieves the attribute found at `path` and populates +// the `target` with the value. +// +// Elements under null or unknown collections return null values, however this +// behavior is not protected by compatibility promises. +func (s ResourceIdentity) GetAttribute(ctx context.Context, path path.Path, target interface{}) diag.Diagnostics { + return s.data().GetAtPath(ctx, path, target) +} + +// PathMatches returns all matching path.Paths from the given path.Expression. +// +// If a parent path is null or unknown, which would prevent a full expression +// from matching, the parent path is returned rather than no match to prevent +// false positives. +func (s ResourceIdentity) PathMatches(ctx context.Context, pathExpr path.Expression) (path.Paths, diag.Diagnostics) { + return s.data().PathMatches(ctx, pathExpr) +} + +// Set populates the entire identity using the supplied Go value. The value `val` +// should be a struct whose values have one of the attr.Value types. Each field +// must be tagged with the corresponding schema field. +func (s *ResourceIdentity) Set(ctx context.Context, val interface{}) diag.Diagnostics { + data := s.data() + diags := data.Set(ctx, val) + + if diags.HasError() { + return diags + } + + s.Raw = data.TerraformValue + + return diags +} + +// SetAttribute sets the attribute at `path` using the supplied Go value. +// +// The attribute path and value must be valid with the current schema. If the +// attribute path already has a value, it will be overwritten. If the attribute +// path does not have a value, it will be added. +// +// The value must not be an untyped nil. Use a typed nil or types package null +// value function instead. For example with a types.StringType attribute, +// use (*string)(nil) or types.StringNull(). +// +// Lists can only have the next element added according to the current length. +func (s *ResourceIdentity) SetAttribute(ctx context.Context, path path.Path, val interface{}) diag.Diagnostics { + data := s.data() + diags := data.SetAtPath(ctx, path, val) + + if diags.HasError() { + return diags + } + + s.Raw = data.TerraformValue + + return diags +} + +func (s ResourceIdentity) data() *fwschemadata.Data { + return &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionResourceIdentity, + Schema: s.Schema, + TerraformValue: s.Raw, + } +} diff --git a/tfsdk/resource_identity_test.go b/tfsdk/resource_identity_test.go new file mode 100644 index 00000000..e73d0787 --- /dev/null +++ b/tfsdk/resource_identity_test.go @@ -0,0 +1,482 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfsdk_test + +import ( + "context" + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + intreflect "github.com/hashicorp/terraform-plugin-framework/internal/reflect" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestResourceIdentityGet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + resourceIdentity tfsdk.ResourceIdentity + target any + expected any + expectedDiags diag.Diagnostics + }{ + // Refer to fwschemadata.TestDataGet for more exhaustive unit testing. + // These test cases are to ensure ResourceIdentity schema and data values are + // passed appropriately to the shared implementation. + "valid": { + resourceIdentity: tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "string": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "string": tftypes.NewValue(tftypes.String, "test"), + }, + ), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "string": testschema.Attribute{ + RequiredForImport: true, + Type: types.StringType, + }, + }, + }, + }, + target: new(struct { + String types.String `tfsdk:"string"` + }), + expected: &struct { + String types.String `tfsdk:"string"` + }{ + String: types.StringValue("test"), + }, + }, + "diagnostic": { + resourceIdentity: tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "bool": tftypes.Bool, + }, + }, + map[string]tftypes.Value{ + "bool": tftypes.NewValue(tftypes.Bool, nil), + }, + ), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "bool": testschema.Attribute{ + RequiredForImport: true, + Type: types.BoolType, + }, + }, + }, + }, + target: new(struct { + String types.String `tfsdk:"bool"` + }), + expected: &struct { + String types.String `tfsdk:"bool"` + }{ + String: types.String{}, + }, + expectedDiags: diag.Diagnostics{ + diag.WithPath( + path.Root("bool"), + intreflect.DiagNewAttributeValueIntoWrongType{ + ValType: reflect.TypeOf(types.Bool{}), + TargetType: reflect.TypeOf(types.String{}), + SchemaType: types.BoolType, + }, + ), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + diags := testCase.resourceIdentity.Get(context.Background(), testCase.target) + + if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { + t.Errorf("unexpected diagnostics (+wanted, -got): %s", diff) + } + + if diff := cmp.Diff(testCase.target, testCase.expected); diff != "" { + t.Errorf("unexpected value (+wanted, -got): %s", diff) + } + }) + } +} + +func TestResourceIdentityGetAttribute(t *testing.T) { + t.Parallel() + + type testCase struct { + resourceIdentity tfsdk.ResourceIdentity + target interface{} + expected interface{} + expectedDiags diag.Diagnostics + } + + testCases := map[string]testCase{ + // Refer to fwschemadata.TestDataGetAtPath for more exhaustive unit + // testing. These test cases are to ensure ResourceIdentity schema and data values + // are passed appropriately to the shared implementation. + "valid": { + resourceIdentity: tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "namevalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "name": testschema.Attribute{ + Type: types.StringType, + RequiredForImport: true, + }, + }, + }, + }, + target: new(string), + expected: pointer("namevalue"), + }, + "diagnostics": { + resourceIdentity: tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "namevalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "name": testschema.Attribute{ + Type: testtypes.StringTypeWithValidateWarning{}, + RequiredForImport: true, + }, + }, + }, + }, + target: new(testtypes.String), + expected: &testtypes.String{InternalString: types.StringValue("namevalue"), CreatedBy: testtypes.StringTypeWithValidateWarning{}}, + expectedDiags: diag.Diagnostics{testtypes.TestWarningDiagnostic(path.Root("name"))}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + diags := tc.resourceIdentity.GetAttribute(context.Background(), path.Root("name"), tc.target) + + if diff := cmp.Diff(diags, tc.expectedDiags); diff != "" { + t.Errorf("unexpected diagnostics (+wanted, -got): %s", diff) + } + + if diff := cmp.Diff(tc.target, tc.expected, cmp.Transformer("testtypes", func(in *testtypes.String) testtypes.String { return *in }), cmp.Transformer("types", func(in *types.String) types.String { return *in })); diff != "" { + t.Errorf("unexpected value (+wanted, -got): %s", diff) + } + }) + } +} + +func TestResourceIdentityPathMatches(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + resourceIdentity tfsdk.ResourceIdentity + expression path.Expression + expected path.Paths + expectedDiags diag.Diagnostics + }{ + // Refer to fwschemadata.TestDataPathMatches for more exhaustive unit testing. + // These test cases are to ensure ResourceIdentity schema and data values are + // passed appropriately to the shared implementation. + "AttributeNameExact-match": { + resourceIdentity: tfsdk.ResourceIdentity{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.StringType, + RequiredForImport: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expression: path.MatchRoot("test"), + expected: path.Paths{ + path.Root("test"), + }, + }, + "AttributeNameExact-mismatch": { + resourceIdentity: tfsdk.ResourceIdentity{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.StringType, + RequiredForImport: true, + }, + }, + }, + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + }, + expression: path.MatchRoot("not-test"), + expected: nil, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema", + "The Terraform Provider unexpectedly provided a path expression that does not match the current schema. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test", + ), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := testCase.resourceIdentity.PathMatches(context.Background(), testCase.expression) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiags); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} + +func TestResourceIdentitySet(t *testing.T) { + t.Parallel() + + type testCase struct { + resourceIdentity tfsdk.ResourceIdentity + val interface{} + expected tftypes.Value + expectedDiags diag.Diagnostics + } + + testCases := map[string]testCase{ + // Refer to fwschemadata.TestDataSet for more exhaustive unit testing. + // These test cases are to ensure ResourceIdentity schema and data values are + // passed appropriately to the shared implementation. + "valid": { + resourceIdentity: tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "oldvalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "name": testschema.Attribute{ + Type: types.StringType, + RequiredForImport: true, + }, + }, + }, + }, + val: struct { + Name string `tfsdk:"name"` + }{ + Name: "newvalue", + }, + expected: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "newvalue"), + }), + }, + "diagnostics": { + resourceIdentity: tfsdk.ResourceIdentity{ + Raw: tftypes.Value{}, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "name": testschema.Attribute{ + Type: testtypes.StringTypeWithValidateWarning{}, + RequiredForImport: true, + }, + }, + }, + }, + val: struct { + Name string `tfsdk:"name"` + }{ + Name: "newvalue", + }, + expected: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "newvalue"), + }), + expectedDiags: diag.Diagnostics{testtypes.TestWarningDiagnostic(path.Root("name"))}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + diags := tc.resourceIdentity.Set(context.Background(), tc.val) + + if diff := cmp.Diff(diags, tc.expectedDiags); diff != "" { + t.Errorf("unexpected diagnostics (+wanted, -got): %s", diff) + } + + if diff := cmp.Diff(tc.resourceIdentity.Raw, tc.expected); diff != "" { + t.Errorf("unexpected value (+wanted, -got): %s", diff) + } + }) + } +} + +func TestResourceIdentitySetAttribute(t *testing.T) { + t.Parallel() + + type testCase struct { + resourceIdentity tfsdk.ResourceIdentity + path path.Path + val interface{} + expected tftypes.Value + expectedDiags diag.Diagnostics + } + + testCases := map[string]testCase{ + // Refer to fwschemadata.TestDataSetAtPath for more exhaustive unit + // testing. These test cases are to ensure ResourceIdentity schema and data values + // are passed appropriately to the shared implementation. + "valid": { + resourceIdentity: tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "originalvalue"), + "other": tftypes.NewValue(tftypes.String, "should be untouched"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.StringType, + RequiredForImport: true, + }, + "other": testschema.Attribute{ + Type: types.StringType, + OptionalForImport: true, + }, + }, + }, + }, + path: path.Root("test"), + val: "newvalue", + expected: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "newvalue"), + "other": tftypes.NewValue(tftypes.String, "should be untouched"), + }), + }, + "diagnostics": { + resourceIdentity: tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "originalname"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "name": testschema.Attribute{ + Type: testtypes.StringTypeWithValidateWarning{}, + RequiredForImport: true, + }, + }, + }, + }, + path: path.Root("name"), + val: "newname", + expected: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "newname"), + }), + expectedDiags: diag.Diagnostics{ + testtypes.TestWarningDiagnostic(path.Root("name")), + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + diags := tc.resourceIdentity.SetAttribute(context.Background(), tc.path, tc.val) + + if diff := cmp.Diff(diags, tc.expectedDiags); diff != "" { + for _, diagnostic := range diags { + t.Log(diagnostic) + } + t.Errorf("unexpected diagnostics (+wanted, -got): %s", diff) + } + + if diff := cmp.Diff(tc.resourceIdentity.Raw, tc.expected); diff != "" { + t.Errorf("unexpected value (+wanted, -got): %s", diff) + } + }) + } +}