diff --git a/.changes/unreleased/FEATURES-20250210-175144.yaml b/.changes/unreleased/FEATURES-20250210-175144.yaml new file mode 100644 index 000000000..72166aede --- /dev/null +++ b/.changes/unreleased/FEATURES-20250210-175144.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'tfprotov5+tfprotov6: Upgraded protocols and added types to support the new resource identity feature' +time: 2025-02-10T17:51:44.461603+01:00 +custom: + Issue: "476" diff --git a/.changes/unreleased/NOTES-20250210-175414.yaml b/.changes/unreleased/NOTES-20250210-175414.yaml new file mode 100644 index 000000000..e3c64a48f --- /dev/null +++ b/.changes/unreleased/NOTES-20250210-175414.yaml @@ -0,0 +1,5 @@ +kind: NOTES +body: 'tfprotov5+tfprotov6: An upcoming release will require the `GetResourceIdentitySchemas` and `UpgradeResourceIdentity` implementations as part of `ProviderServer`.' +time: 2025-02-10T17:54:14.054598+01:00 +custom: + Issue: "476" diff --git a/tfprotov5/provider.go b/tfprotov5/provider.go index f229fff3c..ae5caa256 100644 --- a/tfprotov5/provider.go +++ b/tfprotov5/provider.go @@ -177,7 +177,7 @@ type GetResourceIdentitySchemasRequest struct{} // GetResourceIdentitySchemasResponse represents a Terraform RPC response containing // the provider's resource identity schemas. type GetResourceIdentitySchemasResponse struct { - // ResourceSchemas is a map of resource names to the schema for the + // IdentitySchemas is a map of resource names to the schema for the // identity specified for the resource. The name should be a // resource name, and should be prefixed with your provider's shortname // and an underscore. It should match the first label after `resource` diff --git a/tfprotov6/internal/fromproto/provider.go b/tfprotov6/internal/fromproto/provider.go index 99a6cc556..4c5565cd5 100644 --- a/tfprotov6/internal/fromproto/provider.go +++ b/tfprotov6/internal/fromproto/provider.go @@ -28,6 +28,16 @@ func GetProviderSchemaRequest(in *tfplugin6.GetProviderSchema_Request) *tfprotov return resp } +func GetResourceIdentitySchemasRequest(in *tfplugin6.GetResourceIdentitySchemas_Request) *tfprotov6.GetResourceIdentitySchemasRequest { + if in == nil { + return nil + } + + resp := &tfprotov6.GetResourceIdentitySchemasRequest{} + + return resp +} + func ValidateProviderConfigRequest(in *tfplugin6.ValidateProviderConfig_Request) *tfprotov6.ValidateProviderConfigRequest { if in == nil { return nil diff --git a/tfprotov6/internal/fromproto/provider_test.go b/tfprotov6/internal/fromproto/provider_test.go index 2b83c6754..ad8372e86 100644 --- a/tfprotov6/internal/fromproto/provider_test.go +++ b/tfprotov6/internal/fromproto/provider_test.go @@ -72,6 +72,37 @@ func TestGetProviderSchemaRequest(t *testing.T) { } } +func TestGetResourceIdentitySchemasRequest(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfplugin6.GetResourceIdentitySchemas_Request + expected *tfprotov6.GetResourceIdentitySchemasRequest + }{ + "nil": { + in: nil, + expected: nil, + }, + "zero": { + in: &tfplugin6.GetResourceIdentitySchemas_Request{}, + expected: &tfprotov6.GetResourceIdentitySchemasRequest{}, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := fromproto.GetResourceIdentitySchemasRequest(testCase.in) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestConfigureProviderRequest(t *testing.T) { t.Parallel() diff --git a/tfprotov6/internal/fromproto/raw_identity.go b/tfprotov6/internal/fromproto/raw_identity.go new file mode 100644 index 000000000..73143e4bf --- /dev/null +++ b/tfprotov6/internal/fromproto/raw_identity.go @@ -0,0 +1,20 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto + +import ( + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +func RawIdentity(in []byte) *tfprotov6.RawIdentity { + if in == nil { + return nil + } + + resp := &tfprotov6.RawIdentity{ + JSON: in, + } + + return resp +} diff --git a/tfprotov6/internal/fromproto/raw_identity_test.go b/tfprotov6/internal/fromproto/raw_identity_test.go new file mode 100644 index 000000000..ba7c131cc --- /dev/null +++ b/tfprotov6/internal/fromproto/raw_identity_test.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +func testTfprotov6RawIdentity(t *testing.T, json []byte) *tfprotov6.RawIdentity { + t.Helper() + + return &tfprotov6.RawIdentity{ + JSON: json, + } +} diff --git a/tfprotov6/internal/fromproto/resource.go b/tfprotov6/internal/fromproto/resource.go index 406158f67..97a5a9941 100644 --- a/tfprotov6/internal/fromproto/resource.go +++ b/tfprotov6/internal/fromproto/resource.go @@ -36,6 +36,20 @@ func UpgradeResourceStateRequest(in *tfplugin6.UpgradeResourceState_Request) *tf return resp } +func UpgradeResourceIdentityRequest(in *tfplugin6.UpgradeResourceIdentity_Request) *tfprotov6.UpgradeResourceIdentityRequest { + if in == nil { + return nil + } + + resp := &tfprotov6.UpgradeResourceIdentityRequest{ + RawIdentity: RawIdentity(in.RawIdentity), + TypeName: in.TypeName, + Version: in.Version, + } + + return resp +} + func ReadResourceRequest(in *tfplugin6.ReadResource_Request) *tfprotov6.ReadResourceRequest { if in == nil { return nil @@ -47,6 +61,7 @@ func ReadResourceRequest(in *tfplugin6.ReadResource_Request) *tfprotov6.ReadReso ProviderMeta: DynamicValue(in.ProviderMeta), TypeName: in.TypeName, ClientCapabilities: ReadResourceClientCapabilities(in.ClientCapabilities), + CurrentIdentity: ResourceIdentityData(in.CurrentIdentity), } return resp @@ -65,6 +80,7 @@ func PlanResourceChangeRequest(in *tfplugin6.PlanResourceChange_Request) *tfprot ProviderMeta: DynamicValue(in.ProviderMeta), TypeName: in.TypeName, ClientCapabilities: PlanResourceChangeClientCapabilities(in.ClientCapabilities), + PriorIdentity: ResourceIdentityData(in.PriorIdentity), } return resp @@ -76,12 +92,13 @@ func ApplyResourceChangeRequest(in *tfplugin6.ApplyResourceChange_Request) *tfpr } resp := &tfprotov6.ApplyResourceChangeRequest{ - Config: DynamicValue(in.Config), - PlannedPrivate: in.PlannedPrivate, - PlannedState: DynamicValue(in.PlannedState), - PriorState: DynamicValue(in.PriorState), - ProviderMeta: DynamicValue(in.ProviderMeta), - TypeName: in.TypeName, + Config: DynamicValue(in.Config), + PlannedPrivate: in.PlannedPrivate, + PlannedState: DynamicValue(in.PlannedState), + PriorState: DynamicValue(in.PriorState), + ProviderMeta: DynamicValue(in.ProviderMeta), + TypeName: in.TypeName, + PlannedIdentity: ResourceIdentityData(in.PlannedIdentity), } return resp @@ -96,6 +113,7 @@ func ImportResourceStateRequest(in *tfplugin6.ImportResourceState_Request) *tfpr TypeName: in.TypeName, ID: in.Id, ClientCapabilities: ImportResourceStateClientCapabilities(in.ClientCapabilities), + Identity: ResourceIdentityData(in.Identity), } return resp @@ -113,6 +131,7 @@ func MoveResourceStateRequest(in *tfplugin6.MoveResourceState_Request) *tfprotov SourceState: RawState(in.SourceState), SourceTypeName: in.SourceTypeName, TargetTypeName: in.TargetTypeName, + SourceIdentity: ResourceIdentityData(in.SourceIdentity), } return resp diff --git a/tfprotov6/internal/fromproto/resource_identity_data.go b/tfprotov6/internal/fromproto/resource_identity_data.go new file mode 100644 index 000000000..f1fca647f --- /dev/null +++ b/tfprotov6/internal/fromproto/resource_identity_data.go @@ -0,0 +1,21 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto + +import ( + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6/internal/tfplugin6" +) + +func ResourceIdentityData(in *tfplugin6.ResourceIdentityData) *tfprotov6.ResourceIdentityData { + if in == nil { + return nil + } + + resp := &tfprotov6.ResourceIdentityData{ + IdentityData: DynamicValue(in.IdentityData), + } + + return resp +} diff --git a/tfprotov6/internal/fromproto/resource_identity_data_test.go b/tfprotov6/internal/fromproto/resource_identity_data_test.go new file mode 100644 index 000000000..0ab4804ef --- /dev/null +++ b/tfprotov6/internal/fromproto/resource_identity_data_test.go @@ -0,0 +1,20 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto_test + +import ( + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6/internal/tfplugin6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6/internal/toproto" +) + +func testTfplugin6ResourceIdentityData() *tfplugin6.ResourceIdentityData { + return toproto.ResourceIdentityData(testTfprotov6ResourceIdentityData()) +} + +func testTfprotov6ResourceIdentityData() *tfprotov6.ResourceIdentityData { + return &tfprotov6.ResourceIdentityData{ + IdentityData: testTfprotov6DynamicValue(), + } +} diff --git a/tfprotov6/internal/fromproto/resource_test.go b/tfprotov6/internal/fromproto/resource_test.go index 15caef7c7..a6bf3c77d 100644 --- a/tfprotov6/internal/fromproto/resource_test.go +++ b/tfprotov6/internal/fromproto/resource_test.go @@ -76,6 +76,14 @@ func TestApplyResourceChangeRequest(t *testing.T) { TypeName: "test", }, }, + "PlannedIdentity": { + in: &tfplugin6.ApplyResourceChange_Request{ + PlannedIdentity: testTfplugin6ResourceIdentityData(), + }, + expected: &tfprotov6.ApplyResourceChangeRequest{ + PlannedIdentity: testTfprotov6ResourceIdentityData(), + }, + }, } for name, testCase := range testCases { @@ -134,6 +142,14 @@ func TestImportResourceStateRequest(t *testing.T) { }, }, }, + "Identity": { + in: &tfplugin6.ImportResourceState_Request{ + Identity: testTfplugin6ResourceIdentityData(), + }, + expected: &tfprotov6.ImportResourceStateRequest{ + Identity: testTfprotov6ResourceIdentityData(), + }, + }, } for name, testCase := range testCases { @@ -212,6 +228,14 @@ func TestMoveResourceStateRequest(t *testing.T) { TargetTypeName: "test", }, }, + "SourceIdentity": { + in: &tfplugin6.MoveResourceState_Request{ + SourceIdentity: testTfplugin6ResourceIdentityData(), + }, + expected: &tfprotov6.MoveResourceStateRequest{ + SourceIdentity: testTfprotov6ResourceIdentityData(), + }, + }, } for name, testCase := range testCases { @@ -302,6 +326,14 @@ func TestPlanResourceChangeRequest(t *testing.T) { }, }, }, + "PriorIdentity": { + in: &tfplugin6.PlanResourceChange_Request{ + PriorIdentity: testTfplugin6ResourceIdentityData(), + }, + expected: &tfprotov6.PlanResourceChangeRequest{ + PriorIdentity: testTfprotov6ResourceIdentityData(), + }, + }, } for name, testCase := range testCases { @@ -376,6 +408,14 @@ func TestReadResourceRequest(t *testing.T) { }, }, }, + "CurrentIdentity": { + in: &tfplugin6.ReadResource_Request{ + CurrentIdentity: testTfplugin6ResourceIdentityData(), + }, + expected: &tfprotov6.ReadResourceRequest{ + CurrentIdentity: testTfprotov6ResourceIdentityData(), + }, + }, } for name, testCase := range testCases { @@ -445,6 +485,61 @@ func TestUpgradeResourceStateRequest(t *testing.T) { } } +func TestUpgradeResourceIdentityRequest(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfplugin6.UpgradeResourceIdentity_Request + expected *tfprotov6.UpgradeResourceIdentityRequest + }{ + "nil": { + in: nil, + expected: nil, + }, + "zero": { + in: &tfplugin6.UpgradeResourceIdentity_Request{}, + expected: &tfprotov6.UpgradeResourceIdentityRequest{}, + }, + "RawIdentity": { + in: &tfplugin6.UpgradeResourceIdentity_Request{ + RawIdentity: []byte("{}"), + }, + expected: &tfprotov6.UpgradeResourceIdentityRequest{ + RawIdentity: testTfprotov6RawIdentity(t, []byte("{}")), + }, + }, + "TypeName": { + in: &tfplugin6.UpgradeResourceIdentity_Request{ + TypeName: "test", + }, + expected: &tfprotov6.UpgradeResourceIdentityRequest{ + TypeName: "test", + }, + }, + "Version": { + in: &tfplugin6.UpgradeResourceIdentity_Request{ + Version: 123, + }, + expected: &tfprotov6.UpgradeResourceIdentityRequest{ + Version: 123, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := fromproto.UpgradeResourceIdentityRequest(testCase.in) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestValidateResourceConfigRequest(t *testing.T) { t.Parallel() diff --git a/tfprotov6/internal/toproto/provider.go b/tfprotov6/internal/toproto/provider.go index b0a4c3149..6f4aa7dc8 100644 --- a/tfprotov6/internal/toproto/provider.go +++ b/tfprotov6/internal/toproto/provider.go @@ -76,6 +76,23 @@ func GetProviderSchema_Response(in *tfprotov6.GetProviderSchemaResponse) *tfplug return resp } +func GetResourceIdentitySchemas_Response(in *tfprotov6.GetResourceIdentitySchemasResponse) *tfplugin6.GetResourceIdentitySchemas_Response { + if in == nil { + return nil + } + + resp := &tfplugin6.GetResourceIdentitySchemas_Response{ + Diagnostics: Diagnostics(in.Diagnostics), + IdentitySchemas: make(map[string]*tfplugin6.ResourceIdentitySchema, len(in.IdentitySchemas)), + } + + for name, schema := range in.IdentitySchemas { + resp.IdentitySchemas[name] = ResourceIdentitySchema(schema) + } + + return resp +} + func ValidateProviderConfig_Response(in *tfprotov6.ValidateProviderConfigResponse) *tfplugin6.ValidateProviderConfig_Response { if in == nil { return nil diff --git a/tfprotov6/internal/toproto/provider_test.go b/tfprotov6/internal/toproto/provider_test.go index 0e6ded0d9..d45ea9d1a 100644 --- a/tfprotov6/internal/toproto/provider_test.go +++ b/tfprotov6/internal/toproto/provider_test.go @@ -499,6 +499,105 @@ func TestGetProviderSchema_Response(t *testing.T) { } } +func TestGetResourceIdentitySchemas_Response(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov6.GetResourceIdentitySchemasResponse + expected *tfplugin6.GetResourceIdentitySchemas_Response + }{ + "nil": { + in: nil, + expected: nil, + }, + "zero": { + in: &tfprotov6.GetResourceIdentitySchemasResponse{}, + expected: &tfplugin6.GetResourceIdentitySchemas_Response{ + Diagnostics: []*tfplugin6.Diagnostic{}, + IdentitySchemas: map[string]*tfplugin6.ResourceIdentitySchema{}, + }, + }, + "Diagnostics": { + in: &tfprotov6.GetResourceIdentitySchemasResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + testTfprotov6Diagnostic, + }, + }, + expected: &tfplugin6.GetResourceIdentitySchemas_Response{ + Diagnostics: []*tfplugin6.Diagnostic{ + testTfplugin6Diagnostic, + }, + IdentitySchemas: map[string]*tfplugin6.ResourceIdentitySchema{}, + }, + }, + "IdentitySchemas": { + in: &tfprotov6.GetResourceIdentitySchemasResponse{ + IdentitySchemas: map[string]*tfprotov6.ResourceIdentitySchema{ + "test": { + Version: 1, + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "req", + RequiredForImport: true, + Description: "this one's required", + }, + { + Name: "opt", + OptionalForImport: true, + Description: "this one's optional", + }, + }, + }, + }, + }, + expected: &tfplugin6.GetResourceIdentitySchemas_Response{ + Diagnostics: []*tfplugin6.Diagnostic{}, + IdentitySchemas: map[string]*tfplugin6.ResourceIdentitySchema{ + "test": { + Version: 1, + IdentityAttributes: []*tfplugin6.ResourceIdentitySchema_IdentityAttribute{ + { + Name: "req", + RequiredForImport: true, + Description: "this one's required", + }, + { + Name: "opt", + OptionalForImport: true, + Description: "this one's optional", + }, + }, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto.GetResourceIdentitySchemas_Response(testCase.in) + + // Protocol Buffers generated types must have unexported fields + // ignored or cmp.Diff() will raise an error. This is easier than + // writing a custom Comparer for each type, which would have no + // benefits. + diffOpts := cmpopts.IgnoreUnexported( + tfplugin6.Diagnostic{}, + tfplugin6.GetResourceIdentitySchemas_Response{}, + tfplugin6.ResourceIdentitySchema{}, + tfplugin6.ResourceIdentitySchema_IdentityAttribute{}, + ) + + if diff := cmp.Diff(got, testCase.expected, diffOpts); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestValidateProviderConfig_Response(t *testing.T) { t.Parallel() diff --git a/tfprotov6/internal/toproto/resource.go b/tfprotov6/internal/toproto/resource.go index 876ba5d26..39bdef5ff 100644 --- a/tfprotov6/internal/toproto/resource.go +++ b/tfprotov6/internal/toproto/resource.go @@ -45,6 +45,19 @@ func UpgradeResourceState_Response(in *tfprotov6.UpgradeResourceStateResponse) * return resp } +func UpgradeResourceIdentity_Response(in *tfprotov6.UpgradeResourceIdentityResponse) *tfplugin6.UpgradeResourceIdentity_Response { + if in == nil { + return nil + } + + resp := &tfplugin6.UpgradeResourceIdentity_Response{ + Diagnostics: Diagnostics(in.Diagnostics), + UpgradedIdentity: ResourceIdentityData(in.UpgradedIdentity), + } + + return resp +} + func ReadResource_Response(in *tfprotov6.ReadResourceResponse) *tfplugin6.ReadResource_Response { if in == nil { return nil @@ -55,6 +68,7 @@ func ReadResource_Response(in *tfprotov6.ReadResourceResponse) *tfplugin6.ReadRe NewState: DynamicValue(in.NewState), Private: in.Private, Deferred: Deferred(in.Deferred), + NewIdentity: ResourceIdentityData(in.NewIdentity), } return resp @@ -72,6 +86,7 @@ func PlanResourceChange_Response(in *tfprotov6.PlanResourceChangeResponse) *tfpl PlannedState: DynamicValue(in.PlannedState), RequiresReplace: AttributePaths(in.RequiresReplace), Deferred: Deferred(in.Deferred), + PlannedIdentity: ResourceIdentityData(in.PlannedIdentity), } return resp @@ -87,6 +102,7 @@ func ApplyResourceChange_Response(in *tfprotov6.ApplyResourceChangeResponse) *tf LegacyTypeSystem: in.UnsafeToUseLegacyTypeSystem, //nolint:staticcheck NewState: DynamicValue(in.NewState), Private: in.Private, + NewIdentity: ResourceIdentityData(in.NewIdentity), } return resp @@ -115,6 +131,7 @@ func ImportResourceState_ImportedResource(in *tfprotov6.ImportedResource) *tfplu Private: in.Private, State: DynamicValue(in.State), TypeName: in.TypeName, + Identity: ResourceIdentityData(in.Identity), } return resp @@ -136,9 +153,10 @@ func MoveResourceState_Response(in *tfprotov6.MoveResourceStateResponse) *tfplug } resp := &tfplugin6.MoveResourceState_Response{ - Diagnostics: Diagnostics(in.Diagnostics), - TargetPrivate: in.TargetPrivate, - TargetState: DynamicValue(in.TargetState), + Diagnostics: Diagnostics(in.Diagnostics), + TargetPrivate: in.TargetPrivate, + TargetState: DynamicValue(in.TargetState), + TargetIdentity: ResourceIdentityData(in.TargetIdentity), } return resp diff --git a/tfprotov6/internal/toproto/resource_identity_data.go b/tfprotov6/internal/toproto/resource_identity_data.go new file mode 100644 index 000000000..a1af444b8 --- /dev/null +++ b/tfprotov6/internal/toproto/resource_identity_data.go @@ -0,0 +1,21 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto + +import ( + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6/internal/tfplugin6" +) + +func ResourceIdentityData(in *tfprotov6.ResourceIdentityData) *tfplugin6.ResourceIdentityData { + if in == nil { + return nil + } + + resp := &tfplugin6.ResourceIdentityData{ + IdentityData: DynamicValue(in.IdentityData), + } + + return resp +} diff --git a/tfprotov6/internal/toproto/resource_identity_data_test.go b/tfprotov6/internal/toproto/resource_identity_data_test.go new file mode 100644 index 000000000..c911cf507 --- /dev/null +++ b/tfprotov6/internal/toproto/resource_identity_data_test.go @@ -0,0 +1,20 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto_test + +import ( + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6/internal/fromproto" + "github.com/hashicorp/terraform-plugin-go/tfprotov6/internal/tfplugin6" +) + +func testTfprotov6ResourceIdentityData() *tfprotov6.ResourceIdentityData { + return fromproto.ResourceIdentityData(testTfplugin6ResourceIdentityData()) +} + +func testTfplugin6ResourceIdentityData() *tfplugin6.ResourceIdentityData { + return &tfplugin6.ResourceIdentityData{ + IdentityData: testTfplugin6DynamicValue(), + } +} diff --git a/tfprotov6/internal/toproto/resource_identity_schema.go b/tfprotov6/internal/toproto/resource_identity_schema.go new file mode 100644 index 000000000..d418b18c6 --- /dev/null +++ b/tfprotov6/internal/toproto/resource_identity_schema.go @@ -0,0 +1,52 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto + +import ( + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6/internal/tfplugin6" +) + +func ResourceIdentitySchema(in *tfprotov6.ResourceIdentitySchema) *tfplugin6.ResourceIdentitySchema { + if in == nil { + return nil + } + + resp := &tfplugin6.ResourceIdentitySchema{ + Version: in.Version, + IdentityAttributes: ResourceIdentitySchema_IdentityAttributes(in.IdentityAttributes), + } + + return resp +} + +func ResourceIdentitySchema_IdentityAttribute(in *tfprotov6.ResourceIdentitySchemaAttribute) *tfplugin6.ResourceIdentitySchema_IdentityAttribute { + if in == nil { + return nil + } + + resp := &tfplugin6.ResourceIdentitySchema_IdentityAttribute{ + Name: in.Name, + Type: CtyType(in.Type), + RequiredForImport: in.RequiredForImport, + OptionalForImport: in.OptionalForImport, + Description: in.Description, + } + + return resp +} + +func ResourceIdentitySchema_IdentityAttributes(in []*tfprotov6.ResourceIdentitySchemaAttribute) []*tfplugin6.ResourceIdentitySchema_IdentityAttribute { + if in == nil { + return nil + } + + resp := make([]*tfplugin6.ResourceIdentitySchema_IdentityAttribute, 0, len(in)) + + for _, a := range in { + resp = append(resp, ResourceIdentitySchema_IdentityAttribute(a)) + } + + return resp +} diff --git a/tfprotov6/internal/toproto/resource_identity_schema_test.go b/tfprotov6/internal/toproto/resource_identity_schema_test.go new file mode 100644 index 000000000..835ffae51 --- /dev/null +++ b/tfprotov6/internal/toproto/resource_identity_schema_test.go @@ -0,0 +1,228 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/terraform-plugin-go/tfprotov6/internal/toproto" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6/internal/tfplugin6" +) + +func TestResourceIdentitySchema(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov6.ResourceIdentitySchema + expected *tfplugin6.ResourceIdentitySchema + }{ + "nil": { + in: nil, + expected: nil, + }, + "zero": { + in: &tfprotov6.ResourceIdentitySchema{}, + expected: &tfplugin6.ResourceIdentitySchema{}, + }, + "IdentityAttributes": { + in: &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "test", + }, + }, + }, + expected: &tfplugin6.ResourceIdentitySchema{ + IdentityAttributes: []*tfplugin6.ResourceIdentitySchema_IdentityAttribute{ + { + Name: "test", + }, + }, + }, + }, + "Version": { + in: &tfprotov6.ResourceIdentitySchema{ + Version: 123, + }, + expected: &tfplugin6.ResourceIdentitySchema{ + Version: 123, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto.ResourceIdentitySchema(testCase.in) + + // Protocol Buffers generated types must have unexported fields + // ignored or cmp.Diff() will raise an error. This is easier than + // writing a custom Comparer for each type, which would have no + // benefits. + diffOpts := cmpopts.IgnoreUnexported( + tfplugin6.ResourceIdentitySchema{}, + tfplugin6.ResourceIdentitySchema_IdentityAttribute{}, + ) + + if diff := cmp.Diff(got, testCase.expected, diffOpts); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestResourceIdentitySchema_IdentityAttribute(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov6.ResourceIdentitySchemaAttribute + expected *tfplugin6.ResourceIdentitySchema_IdentityAttribute + }{ + "nil": { + in: nil, + expected: nil, + }, + "zero": { + in: &tfprotov6.ResourceIdentitySchemaAttribute{}, + expected: &tfplugin6.ResourceIdentitySchema_IdentityAttribute{}, + }, + "Name": { + in: &tfprotov6.ResourceIdentitySchemaAttribute{ + Name: "test", + }, + expected: &tfplugin6.ResourceIdentitySchema_IdentityAttribute{ + Name: "test", + }, + }, + "Type": { + in: &tfprotov6.ResourceIdentitySchemaAttribute{ + Type: tftypes.Bool, + }, + expected: &tfplugin6.ResourceIdentitySchema_IdentityAttribute{ + Type: []byte(`"bool"`), + }, + }, + "RequiredForImport": { + in: &tfprotov6.ResourceIdentitySchemaAttribute{ + RequiredForImport: true, + }, + expected: &tfplugin6.ResourceIdentitySchema_IdentityAttribute{ + RequiredForImport: true, + }, + }, + "OptionalForImport": { + in: &tfprotov6.ResourceIdentitySchemaAttribute{ + OptionalForImport: true, + }, + expected: &tfplugin6.ResourceIdentitySchema_IdentityAttribute{ + OptionalForImport: true, + }, + }, + "Description": { + in: &tfprotov6.ResourceIdentitySchemaAttribute{ + Description: "test", + }, + expected: &tfplugin6.ResourceIdentitySchema_IdentityAttribute{ + Description: "test", + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto.ResourceIdentitySchema_IdentityAttribute(testCase.in) + + // Protocol Buffers generated types must have unexported fields + // ignored or cmp.Diff() will raise an error. This is easier than + // writing a custom Comparer for each type, which would have no + // benefits. + diffOpts := cmpopts.IgnoreUnexported( + tfplugin6.ResourceIdentitySchema_IdentityAttribute{}, + ) + + if diff := cmp.Diff(got, testCase.expected, diffOpts); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestResourceIdentitySchema_IdentityAttributes(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in []*tfprotov6.ResourceIdentitySchemaAttribute + expected []*tfplugin6.ResourceIdentitySchema_IdentityAttribute + }{ + "nil": { + in: nil, + expected: nil, + }, + "zero": { + in: []*tfprotov6.ResourceIdentitySchemaAttribute{}, + expected: []*tfplugin6.ResourceIdentitySchema_IdentityAttribute{}, + }, + "one": { + in: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "test", + }, + }, + expected: []*tfplugin6.ResourceIdentitySchema_IdentityAttribute{ + { + Name: "test", + }, + }, + }, + "two": { + in: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "test1", + }, + { + Name: "test2", + }, + }, + expected: []*tfplugin6.ResourceIdentitySchema_IdentityAttribute{ + { + Name: "test1", + }, + { + Name: "test2", + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto.ResourceIdentitySchema_IdentityAttributes(testCase.in) + + // Protocol Buffers generated types must have unexported fields + // ignored or cmp.Diff() will raise an error. This is easier than + // writing a custom Comparer for each type, which would have no + // benefits. + diffOpts := cmpopts.IgnoreUnexported( + tfplugin6.ResourceIdentitySchema_IdentityAttribute{}, + ) + + if diff := cmp.Diff(got, testCase.expected, diffOpts); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/tfprotov6/internal/toproto/resource_test.go b/tfprotov6/internal/toproto/resource_test.go index b6a3d6ebf..90a96bab1 100644 --- a/tfprotov6/internal/toproto/resource_test.go +++ b/tfprotov6/internal/toproto/resource_test.go @@ -70,6 +70,15 @@ func TestApplyResourceChange_Response(t *testing.T) { LegacyTypeSystem: true, }, }, + "NewIdentity": { + in: &tfprotov6.ApplyResourceChangeResponse{ + NewIdentity: testTfprotov6ResourceIdentityData(), + }, + expected: &tfplugin6.ApplyResourceChange_Response{ + Diagnostics: []*tfplugin6.Diagnostic{}, + NewIdentity: testTfplugin6ResourceIdentityData(), + }, + }, } for name, testCase := range testCases { @@ -88,6 +97,7 @@ func TestApplyResourceChange_Response(t *testing.T) { tfplugin6.Diagnostic{}, tfplugin6.DynamicValue{}, tfplugin6.ApplyResourceChange_Response{}, + tfplugin6.ResourceIdentityData{}, ) if diff := cmp.Diff(got, testCase.expected, diffOpts); diff != "" { @@ -270,6 +280,14 @@ func TestImportResourceState_ImportedResource(t *testing.T) { TypeName: "test", }, }, + "Identity": { + in: &tfprotov6.ImportedResource{ + Identity: testTfprotov6ResourceIdentityData(), + }, + expected: &tfplugin6.ImportResourceState_ImportedResource{ + Identity: testTfplugin6ResourceIdentityData(), + }, + }, } for name, testCase := range testCases { @@ -285,6 +303,7 @@ func TestImportResourceState_ImportedResource(t *testing.T) { diffOpts := cmpopts.IgnoreUnexported( tfplugin6.DynamicValue{}, tfplugin6.ImportResourceState_ImportedResource{}, + tfplugin6.ResourceIdentityData{}, ) if diff := cmp.Diff(got, testCase.expected, diffOpts); diff != "" { @@ -530,6 +549,16 @@ func TestPlanResourceChange_Response(t *testing.T) { }, }, }, + "PlannedIdentity": { + in: &tfprotov6.PlanResourceChangeResponse{ + PlannedIdentity: testTfprotov6ResourceIdentityData(), + }, + expected: &tfplugin6.PlanResourceChange_Response{ + Diagnostics: []*tfplugin6.Diagnostic{}, + RequiresReplace: []*tfplugin6.AttributePath{}, + PlannedIdentity: testTfplugin6ResourceIdentityData(), + }, + }, } for name, testCase := range testCases { @@ -549,6 +578,7 @@ func TestPlanResourceChange_Response(t *testing.T) { tfplugin6.DynamicValue{}, tfplugin6.PlanResourceChange_Response{}, tfplugin6.Deferred{}, + tfplugin6.ResourceIdentityData{}, ) if diff := cmp.Diff(got, testCase.expected, diffOpts); diff != "" { @@ -618,6 +648,15 @@ func TestReadResource_Response(t *testing.T) { }, }, }, + "NewIdentity": { + in: &tfprotov6.ReadResourceResponse{ + NewIdentity: testTfprotov6ResourceIdentityData(), + }, + expected: &tfplugin6.ReadResource_Response{ + Diagnostics: []*tfplugin6.Diagnostic{}, + NewIdentity: testTfplugin6ResourceIdentityData(), + }, + }, } for name, testCase := range testCases { @@ -635,6 +674,7 @@ func TestReadResource_Response(t *testing.T) { tfplugin6.DynamicValue{}, tfplugin6.ReadResource_Response{}, tfplugin6.Deferred{}, + tfplugin6.ResourceIdentityData{}, ) if diff := cmp.Diff(got, testCase.expected, diffOpts); diff != "" { @@ -707,6 +747,71 @@ func TestUpgradeResourceState_Response(t *testing.T) { } } +func TestUpgradeResourceIdentity_Response(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov6.UpgradeResourceIdentityResponse + expected *tfplugin6.UpgradeResourceIdentity_Response + }{ + "nil": { + in: nil, + expected: nil, + }, + "zero": { + in: &tfprotov6.UpgradeResourceIdentityResponse{}, + expected: &tfplugin6.UpgradeResourceIdentity_Response{ + Diagnostics: []*tfplugin6.Diagnostic{}, + }, + }, + "Diagnostics": { + in: &tfprotov6.UpgradeResourceIdentityResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + testTfprotov6Diagnostic, + }, + }, + expected: &tfplugin6.UpgradeResourceIdentity_Response{ + Diagnostics: []*tfplugin6.Diagnostic{ + testTfplugin6Diagnostic, + }, + }, + }, + "UpgradedIdentity": { + in: &tfprotov6.UpgradeResourceIdentityResponse{ + UpgradedIdentity: testTfprotov6ResourceIdentityData(), + }, + expected: &tfplugin6.UpgradeResourceIdentity_Response{ + Diagnostics: []*tfplugin6.Diagnostic{}, + UpgradedIdentity: testTfplugin6ResourceIdentityData(), + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto.UpgradeResourceIdentity_Response(testCase.in) + + // Protocol Buffers generated types must have unexported fields + // ignored or cmp.Diff() will raise an error. This is easier than + // writing a custom Comparer for each type, which would have no + // benefits. + diffOpts := cmpopts.IgnoreUnexported( + tfplugin6.Diagnostic{}, + tfplugin6.DynamicValue{}, + tfplugin6.UpgradeResourceIdentity_Response{}, + tfplugin6.ResourceIdentityData{}, + ) + + if diff := cmp.Diff(got, testCase.expected, diffOpts); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestValidateResourceConfig_Response(t *testing.T) { t.Parallel() diff --git a/tfprotov6/provider.go b/tfprotov6/provider.go index 6776a9d0b..80bfaa60a 100644 --- a/tfprotov6/provider.go +++ b/tfprotov6/provider.go @@ -63,6 +63,29 @@ type ProviderServer interface { EphemeralResourceServer } +// ProviderServerWithResourceIdentity is a temporary interface for servers +// to implement Resource Identity RPC handling with: +// +// - GetResourceIdentitySchemas +// - UpgradeResourceIdentity +// +// Deprecated: All methods will be moved into the +// ProviderServer and ResourceServer interfaces and this interface will be removed in a future +// version. +type ProviderServerWithResourceIdentity interface { + ProviderServer + + // GetResourceIdentitySchemas is called when Terraform needs to know + // what the provider's resource identity schemas are. + GetResourceIdentitySchemas(context.Context, *GetResourceIdentitySchemasRequest) (*GetResourceIdentitySchemasResponse, error) // This will go into the ProviderServer interface + + // UpgradeResourceIdentity is called when Terraform has encountered a + // resource with an identity state in a schema that doesn't match the schema's + // current version. It is the provider's responsibility to modify the + // identity state to upgrade it to the latest state schema. + UpgradeResourceIdentity(context.Context, *UpgradeResourceIdentityRequest) (*UpgradeResourceIdentityResponse, error) // This will go into the ResourceServer interface +} + // GetMetadataRequest represents a GetMetadata RPC request. type GetMetadataRequest struct{} @@ -147,6 +170,26 @@ type GetProviderSchemaResponse struct { Diagnostics []*Diagnostic } +// GetResourceIdentitySchemasRequest represents a Terraform RPC request for the +// provider's resource identity schemas. +type GetResourceIdentitySchemasRequest struct{} + +// GetResourceIdentitySchemasResponse represents a Terraform RPC response containing +// the provider's resource identity schemas. +type GetResourceIdentitySchemasResponse struct { + // IdentitySchemas is a map of resource names to the schema for the + // identity specified for the resource. The name should be a + // resource name, and should be prefixed with your provider's shortname + // and an underscore. It should match the first label after `resource` + // in a user's configuration. + IdentitySchemas map[string]*ResourceIdentitySchema + + // Diagnostics report errors or warnings related to returning the + // provider's resource identity schemas. Returning an empty slice + // indicates success, with no errors or warnings generated. + Diagnostics []*Diagnostic +} + // ValidateProviderConfigRequest represents a Terraform RPC request for the // provider to modify the provider configuration in preparation for Terraform // validating it. diff --git a/tfprotov6/raw_identity.go b/tfprotov6/raw_identity.go new file mode 100644 index 000000000..1536c4c36 --- /dev/null +++ b/tfprotov6/raw_identity.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfprotov6 + +import ( + "errors" + + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// ErrUnknownRawIdentityType is returned when a RawIdentity has no JSON +// bytes set. This should never be returned during the normal operation of a +// provider, and indicates one of the following: +// +// 1. terraform-plugin-go is out of sync with the protocol and should be +// updated. +// +// 2. terrafrom-plugin-go has a bug. +// +// 3. The `RawIdentity` was generated or modified by something other than +// terraform-plugin-go and is no longer a valid value. +var ErrUnknownRawIdentityType = errors.New("RawIdentity had no JSON data set") + +// RawIdentity is the raw, undecoded identity state for providers to upgrade. It is +// undecoded as Terraform, for whatever reason, doesn't have the previous +// schema available to it, and so cannot decode the state itself and pushes +// that responsibility off onto providers. +type RawIdentity struct { + JSON []byte +} + +// Unmarshal returns a `tftypes.Value` that represents the information +// contained in the RawIdentity in an easy-to-interact-with way. It is the +// main purpose of the RawIdentity type, and is how provider developers should +// obtain state values from the UpgradeResourceIdentity RPC call. +// +// Pass in the type you want the `Value` to be interpreted as. Terraform's type +// system encodes in a lossy manner, meaning the type information is not +// preserved losslessly when going over the wire. Sets, lists, and tuples all +// look the same. Objects and maps all look the same, as well, as do +// user-specified values when DynamicPseudoType is used in the schema. +// Fortunately, the provider should already know the type; it should be the +// type of the schema, or DynamicPseudoType if that's what's in the schema. +// `Unmarshal` will then parse the value as though it belongs to that type, if +// possible, and return a `tftypes.Value` with the appropriate information. If +// the data can't be interpreted as that type, an error will be returned saying +// so. In these cases, double check to make sure the schema is declaring the +// same type being passed into `Unmarshal`. +// +// In the event an ErrUnknownRawIdentityType is returned, one of three things +// has happened: +// +// 1. terraform-plugin-go is out of date and out of sync with the protocol, and +// an issue should be opened on its repo to get it updated. +// +// 2. terraform-plugin-go has a bug somewhere, and an issue should be opened on +// its repo to get it fixed. +// +// 3. The provider or a dependency has modified the `RawIdentity` in an +// unsupported way, or has created one from scratch, and should treat it as +// opaque and not modify it, only calling `Unmarshal` on `RawIdentity`s received +// from RPC requests. +func (s RawIdentity) Unmarshal(typ tftypes.Type) (tftypes.Value, error) { + if s.JSON != nil { + return tftypes.ValueFromJSON(s.JSON, typ) //nolint:staticcheck + } + return tftypes.Value{}, ErrUnknownRawIdentityType +} + +// UnmarshalWithOpts is identical to Unmarshal but also accepts a tftypes.UnmarshalOpts which contains +// options that can be used to modify the behaviour when unmarshalling JSON. +func (s RawIdentity) UnmarshalWithOpts(typ tftypes.Type, opts UnmarshalOpts) (tftypes.Value, error) { + if s.JSON != nil { + return tftypes.ValueFromJSONWithOpts(s.JSON, typ, opts.ValueFromJSONOpts) //nolint:staticcheck + } + return tftypes.Value{}, ErrUnknownRawIdentityType +} diff --git a/tfprotov6/raw_identity_test.go b/tfprotov6/raw_identity_test.go new file mode 100644 index 000000000..1f53adb42 --- /dev/null +++ b/tfprotov6/raw_identity_test.go @@ -0,0 +1,85 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfprotov6_test + +import ( + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRawIdentityUnmarshalWithOpts(t *testing.T) { + t.Parallel() + type testCase struct { + RawIdentity tfprotov6.RawIdentity + value tftypes.Value + typ tftypes.Type + opts tfprotov6.UnmarshalOpts + } + tests := map[string]testCase{ + "object-of-bool-number": { + RawIdentity: tfprotov6.RawIdentity{ + JSON: []byte(`{"bool":true,"number":0}`), + }, + value: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "bool": tftypes.Bool, + "number": tftypes.Number, + }, + }, map[string]tftypes.Value{ + "bool": tftypes.NewValue(tftypes.Bool, true), + "number": tftypes.NewValue(tftypes.Number, big.NewFloat(0)), + }), + typ: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "bool": tftypes.Bool, + "number": tftypes.Number, + }, + }, + }, + "object-with-missing-attribute": { + RawIdentity: tfprotov6.RawIdentity{ + JSON: []byte(`{"bool":true,"number":0,"unknown":"whatever"}`), + }, + value: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "bool": tftypes.Bool, + "number": tftypes.Number, + }, + }, map[string]tftypes.Value{ + "bool": tftypes.NewValue(tftypes.Bool, true), + "number": tftypes.NewValue(tftypes.Number, big.NewFloat(0)), + }), + typ: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "bool": tftypes.Bool, + "number": tftypes.Number, + }, + }, + opts: tfprotov6.UnmarshalOpts{ + ValueFromJSONOpts: tftypes.ValueFromJSONOpts{ + IgnoreUndefinedAttributes: true, + }, + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + val, err := test.RawIdentity.UnmarshalWithOpts(test.typ, test.opts) + if err != nil { + t.Fatalf("unexpected error unmarshaling: %s", err) + } + + if diff := cmp.Diff(test.value, val); diff != "" { + t.Errorf("Unexpected results (-wanted +got): %s", diff) + } + }) + } +} diff --git a/tfprotov6/resource.go b/tfprotov6/resource.go index 4ae1d372d..bea2057c5 100644 --- a/tfprotov6/resource.go +++ b/tfprotov6/resource.go @@ -131,6 +131,31 @@ type UpgradeResourceStateResponse struct { Diagnostics []*Diagnostic } +type UpgradeResourceIdentityRequest struct { + // TypeName is the type of resource that Terraform needs to upgrade the + // identity state for. + TypeName string + + // Version is the version of the identity state the resource currently has. + Version int64 + + // RawIdentity is the identity state as Terraform sees it right now. See the + // documentation for `RawIdentity` for information on how to work with the + // data it contains. + RawIdentity *RawIdentity +} + +type UpgradeResourceIdentityResponse struct { + // UpgradedIdentity is the upgraded identity for the resource, represented as + // a `ResourceIdentityData`. + UpgradedIdentity *ResourceIdentityData + + // Diagnostics report errors or warnings related to upgrading the + // identity of the requested resource. Returning an empty slice indicates + // a successful validation with no warnings or errors generated. + Diagnostics []*Diagnostic +} + // ReadResourceRequest is the request Terraform sends when it wants to get the // latest state for a resource. type ReadResourceRequest struct { @@ -172,6 +197,10 @@ type ReadResourceRequest struct { // ClientCapabilities defines optionally supported protocol features for the // ReadResource RPC, such as forward-compatible Terraform behavior changes. ClientCapabilities *ReadResourceClientCapabilities + + // CurrentIdentity is the current identity of the resource as far as + // Terraform knows, represented as a `ResourceIdentityData`. + CurrentIdentity *ResourceIdentityData } // ReadResourceResponse is the response from the provider about the current @@ -200,6 +229,10 @@ type ReadResourceResponse struct { // Deferred is used to indicate to Terraform that the ReadResource operation // needs to be deferred for a reason. Deferred *Deferred + + // NewIdentity is the current identity of the resource according to the + // provider, represented as a `ResourceIdentityData`. + NewIdentity *ResourceIdentityData } // PlanResourceChangeRequest is the request Terraform sends when it is @@ -270,6 +303,10 @@ type PlanResourceChangeRequest struct { // ClientCapabilities defines optionally supported protocol features for the // PlanResourceChange RPC, such as forward-compatible Terraform behavior changes. ClientCapabilities *PlanResourceChangeClientCapabilities + + // PriorIdentity is the identity of the resource before the plan is + // applied, represented as a `ResourceIdentityData`. + PriorIdentity *ResourceIdentityData } // PlanResourceChangeResponse is the response from the provider about what the @@ -352,6 +389,10 @@ type PlanResourceChangeResponse struct { // Deferred is used to indicate to Terraform that the PlanResourceChange operation // needs to be deferred for a reason. Deferred *Deferred + + // PlannedIdentity is the provider's indication of what the identity for the + // resource should be after apply, represented as a `ResourceIdentityData` + PlannedIdentity *ResourceIdentityData } // ApplyResourceChangeRequest is the request Terraform sends when it needs to @@ -414,6 +455,10 @@ type ApplyResourceChangeRequest struct { // // This configuration will have known values for all fields. ProviderMeta *DynamicValue + + // PlannedIdentity is Terraform's plan for what the resource identity should look like + // after the changes are applied, represented as a `ResourceIdentityData`. + PlannedIdentity *ResourceIdentityData } // ApplyResourceChangeResponse is the response from the provider about what the @@ -459,6 +504,10 @@ type ApplyResourceChangeResponse struct { // // Deprecated: Really, just don't use this, you don't need it. UnsafeToUseLegacyTypeSystem bool + + // NewIdentity is the provider's understanding of what the resource's + // identity is after changes are applied, represented as a `ResourceIdentityData`. + NewIdentity *ResourceIdentityData } // ImportResourceStateRequest is the request Terraform sends when it wants a @@ -471,11 +520,17 @@ type ImportResourceStateRequest struct { // or resources. Providers decide and communicate to users the format // for the ID, and use it to determine what resource or resources to // import. + // ID is mutually exclusive with Identity ID string // ClientCapabilities defines optionally supported protocol features for the // ImportResourceState RPC, such as forward-compatible Terraform behavior changes. ClientCapabilities *ImportResourceStateClientCapabilities + + // Identity is the user-supplied identifying information about the resource + // in the form of a `ResourceIdentityData`. + // Identity is mutually exclusive with ID. + Identity *ResourceIdentityData } // ImportResourceStateResponse is the response from the provider about the @@ -514,6 +569,10 @@ type ImportedResource struct { // with requests for this resource. This state will be associated with // the resource, but will not be considered when calculating diffs. Private []byte + + // Identity is the identity of the imported resource in the form + // of a `ResourceIdentityData`. + Identity *ResourceIdentityData } // MoveResourceStateRequest is the request Terraform sends when it requests a @@ -545,6 +604,9 @@ type MoveResourceStateRequest struct { // TargetTypeName is the target resource type for the move request. TargetTypeName string + + // SourceIdentity is the identity of the source resource. + SourceIdentity *ResourceIdentityData } // MoveResourceStateResponse is the response from the provider containing @@ -558,4 +620,7 @@ type MoveResourceStateResponse struct { // Diagnostics report any warnings or errors related to moving the state. Diagnostics []*Diagnostic + + // TargetIdentity is the identity of the target resource. + TargetIdentity *ResourceIdentityData } diff --git a/tfprotov6/resource_identity_data.go b/tfprotov6/resource_identity_data.go new file mode 100644 index 000000000..a48742a0f --- /dev/null +++ b/tfprotov6/resource_identity_data.go @@ -0,0 +1,15 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfprotov6 + +// ResourceIdentityData contains the raw undecoded identity data +// for a resource. +type ResourceIdentityData struct { + // IdentityData is represented as a `DynamicValue`. See the documentation for + // `DynamicValue` for information about safely creating the + // `DynamicValue`. + // The identity should be represented as a tftypes.Object, with each + // attribute and nested block getting its own key and value. + IdentityData *DynamicValue +} diff --git a/tfprotov6/resource_identity_schema.go b/tfprotov6/resource_identity_schema.go new file mode 100644 index 000000000..1ae52f15a --- /dev/null +++ b/tfprotov6/resource_identity_schema.go @@ -0,0 +1,58 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfprotov6 + +import "github.com/hashicorp/terraform-plugin-go/tftypes" + +// ResourceIdentitySchema is the identity schema for a Resource. +type ResourceIdentitySchema struct { + // Version indicates which version of the schema this is. Versions + // should be monotonically incrementing numbers. When Terraform + // encounters a resource identity stored in state with a schema version + // lower that the identity schema version the provider advertises for + // that resource, Terraform requests the provider upgrade the resource's + // identity state. + Version int64 + + // IdentityAttributes is a list of attributes that uniquely identify a + // resource. These attributes are used to identify a resource in the + // state and to import existing resources into the state. + IdentityAttributes []*ResourceIdentitySchemaAttribute +} + +// ResourceIdentitySchemaAttribute represents one value of data within +// resource identity. +// These are always used in resource identity comparisons. +type ResourceIdentitySchemaAttribute struct { + // Name is the name of the attribute. This is what the user will put + // before the equals sign to assign a value to this attribute during import. + Name string + + // Type indicates the type of data the attribute expects. See the + // documentation for the tftypes package for information on what types + // are supported and their behaviors. + // For resource identity Terraform core only supports the following types: + // - bool + // - number + // - string + // - list of bool + // - list of number + // - list of string + Type tftypes.Type + + // RequiredForImport indicates whether this attribute is required to + // import the resource. For example it might be false if the value + // can be derived from provider configuration. Either this or OptionalForImport + // needs to be true. + RequiredForImport bool + + // OptionalForImport indicates whether this attribute is optional to + // import the resource. For example it might be true if the value + // can be derived from provider configuration. Either this or RequiredForImport + // needs to be true. + OptionalForImport bool + + // Description is a human-readable description of the attribute. + Description string +} diff --git a/tfprotov6/tf6server/server.go b/tfprotov6/tf6server/server.go index 8f5a54f9a..17e9b41c9 100644 --- a/tfprotov6/tf6server/server.go +++ b/tfprotov6/tf6server/server.go @@ -541,6 +541,56 @@ func (s *server) GetProviderSchema(ctx context.Context, protoReq *tfplugin6.GetP return protoResp, nil } +func (s *server) GetResourceIdentitySchemas(ctx context.Context, protoReq *tfplugin6.GetResourceIdentitySchemas_Request) (*tfplugin6.GetResourceIdentitySchemas_Response, error) { + rpc := "GetResourceIdentitySchemas" + ctx = s.loggingContext(ctx) + ctx = logging.RpcContext(ctx, rpc) + ctx = s.stoppableContext(ctx) + logging.ProtocolTrace(ctx, "Received request") + defer logging.ProtocolTrace(ctx, "Served request") + + req := fromproto.GetResourceIdentitySchemasRequest(protoReq) + + ctx = tf6serverlogging.DownstreamRequest(ctx) + + // TODO: Remove this check and error in preference of + // s.downstream.GetResourceIdentitySchemas below once ProviderServer interface + // implements this RPC method. + // nolint:staticcheck + resourceIdentityProviderServer, ok := s.downstream.(tfprotov6.ProviderServerWithResourceIdentity) + if !ok { + logging.ProtocolError(ctx, "ProviderServer does not implement GetResourceIdentitySchemas") + + protoResp := &tfplugin6.GetResourceIdentitySchemas_Response{ + Diagnostics: []*tfplugin6.Diagnostic{ + { + Severity: tfplugin6.Diagnostic_ERROR, + Summary: "Provider GetResourceIdentitySchemas Not Implemented", + Detail: "A GetResourceIdentitySchemas call was received by the provider, however the provider does not implement the call. " + + "Either upgrade the provider to a version that implements resource identity support or this is a bug in Terraform that should be reported to the Terraform maintainers.", + }, + }, + } + + return protoResp, nil + } + + // TODO: Update this to call downstream once optional interface is removed + // resp, err := s.downstream.GetResourceIdentitySchemas(ctx, req) + resp, err := resourceIdentityProviderServer.GetResourceIdentitySchemas(ctx, req) + + if err != nil { + logging.ProtocolError(ctx, "Error from downstream", map[string]interface{}{logging.KeyError: err}) + return nil, err + } + + tf6serverlogging.DownstreamResponse(ctx, resp.Diagnostics) + + protoResp := toproto.GetResourceIdentitySchemas_Response(resp) + + return protoResp, nil +} + func (s *server) ConfigureProvider(ctx context.Context, protoReq *tfplugin6.ConfigureProvider_Request) (*tfplugin6.ConfigureProvider_Response, error) { rpc := "ConfigureProvider" ctx = s.loggingContext(ctx) @@ -764,6 +814,60 @@ func (s *server) UpgradeResourceState(ctx context.Context, protoReq *tfplugin6.U return protoResp, nil } +func (s *server) UpgradeResourceIdentity(ctx context.Context, protoReq *tfplugin6.UpgradeResourceIdentity_Request) (*tfplugin6.UpgradeResourceIdentity_Response, error) { + rpc := "UpgradeResourceIdentity" + ctx = s.loggingContext(ctx) + ctx = logging.RpcContext(ctx, rpc) + ctx = logging.ResourceContext(ctx, protoReq.TypeName) + ctx = s.stoppableContext(ctx) + logging.ProtocolTrace(ctx, "Received request") + defer logging.ProtocolTrace(ctx, "Served request") + + req := fromproto.UpgradeResourceIdentityRequest(protoReq) + + ctx = tf6serverlogging.DownstreamRequest(ctx) + + // TODO: Remove this check and error in preference of + // s.downstream.UpgradeResourceIdentity below once ProviderServer interface + // implements this RPC method. + // nolint:staticcheck + resourceIdentityProviderServer, ok := s.downstream.(tfprotov6.ProviderServerWithResourceIdentity) + if !ok { + logging.ProtocolError(ctx, "ProviderServer does not implement UpgradeResourceIdentity") + + protoResp := &tfplugin6.UpgradeResourceIdentity_Response{ + Diagnostics: []*tfplugin6.Diagnostic{ + { + Severity: tfplugin6.Diagnostic_ERROR, + Summary: "Provider UpgradeResourceIdentity Not Implemented", + Detail: "A UpgradeResourceIdentity call was received by the provider, however the provider does not implement the call. " + + "Either upgrade the provider to a version that implements resource identity support or this is a bug in Terraform that should be reported to the Terraform maintainers.", + }, + }, + } + + return protoResp, nil + } + + // TODO: Update this to call downstream once optional interface is removed + // resp, err := s.downstream.UpgradeResourceIdentity(ctx, req) + resp, err := resourceIdentityProviderServer.UpgradeResourceIdentity(ctx, req) + + if err != nil { + logging.ProtocolError(ctx, "Error from downstream", map[string]interface{}{logging.KeyError: err}) + return nil, err + } + + tf6serverlogging.DownstreamResponse(ctx, resp.Diagnostics) + if resp.UpgradedIdentity != nil { + logging.ProtocolData(ctx, s.protocolDataDir, rpc, "Response", "UpgradedResourceIdentity", resp.UpgradedIdentity.IdentityData) + } + + protoResp := toproto.UpgradeResourceIdentity_Response(resp) + + return protoResp, nil +} + func (s *server) ReadResource(ctx context.Context, protoReq *tfplugin6.ReadResource_Request) (*tfplugin6.ReadResource_Response, error) { rpc := "ReadResource" ctx = s.loggingContext(ctx)