From de53cd531e420a747a249139d3e826aaf59f54a9 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 18 Jul 2025 09:28:32 -0400 Subject: [PATCH 01/18] Squashed commit of the following: commit 8dd82417c4faa4c5f8cfe4ca68b937882cad1cdd Merge: e4bcbbfc d4a54160 Author: Austin Valle Date: Thu Jul 17 13:01:08 2025 -0400 Merge branch 'main' into av/action-validation-rpc commit e4bcbbfc7ffb948a526be00c68806fe2d1bdad6e Author: Austin Valle Date: Thu Jul 17 11:03:05 2025 -0400 proto5 and proto6 impl commit 6d3cff2531a602b16b3ea57b06df1976d24d0e55 Author: Austin Valle Date: Thu Jul 17 10:54:54 2025 -0400 fwserver commit 3789aad953efd0a766b00acab4190782084d2eb6 Author: Austin Valle Date: Thu Jul 17 10:51:57 2025 -0400 add validators to schemas commit 8361ea6273a053d530f6eee5aa12a3d68cd5b96c Author: Austin Valle Date: Thu Jul 17 10:14:06 2025 -0400 test provider commit 2f341cb9c717596ce6051c6ac0a293960ff74e9d Author: Austin Valle Date: Thu Jul 17 10:02:09 2025 -0400 external interface commit 43f5717ebe9625e404bde4d28abf9cda09685c9a Author: Austin Valle Date: Thu Jul 17 10:00:53 2025 -0400 go mod commit 68a38e48dcce9c27463188313b269b9a7b4b99f2 Merge: 1503be57 3f40cbdd Author: Austin Valle Date: Wed Jul 16 14:51:50 2025 -0400 Merge branch 'main' into av/all-schemas-types commit 1503be57c52e11f92efe5af767d3a06a629246c0 Author: Austin Valle Date: Wed Jul 16 10:07:09 2025 -0400 update all commented out custom type tests commit 21ffee73a58e346ae2e77a4e0d4b54c45a55788f Author: Austin Valle Date: Tue Jul 15 14:50:56 2025 -0400 add blocks and docs commit ea2deb8e7de0033954ac8930c6a7f8192daa1ee6 Author: Austin Valle Date: Tue Jul 15 14:20:28 2025 -0400 all attributes (primitive, collection, nested) commit 98f7232bce2af5b0e743aec1c9afd60e69cc944f Author: Austin Valle Date: Tue Jul 15 12:21:19 2025 -0400 comments commit 119f4c537583b8ad512522087ce00f01ef09ab60 Author: Austin Valle Date: Tue Jul 15 12:20:16 2025 -0400 mention progress events commit 47f963179f6baf45d517f558511e0391d0688bdf Author: Austin Valle Date: Tue Jul 15 12:17:41 2025 -0400 implementation of sending progress events commit 8fdf71dfd5c95d2a8d73341960363e8698e8d9c8 Merge: 261ef7d6 377d9e3a Author: Austin Valle Date: Tue Jul 15 10:55:48 2025 -0400 Merge branch 'main' into av/unlinked-action-impl commit 261ef7d67c4f3fb8aa0913ac128164d6ce2f48d5 Author: Austin Valle Date: Mon Jul 14 10:46:50 2025 -0400 fix map access in unit tests commit 2f08b28fa62cc114f86de456f8e9577f55c8ecad Author: Austin Valle Date: Mon Jul 14 10:46:44 2025 -0400 add invoke impl with just completed event commit e43f5769dd73a6e88aeff437c4def56cf5e983b9 Author: Austin Valle Date: Mon Jul 14 08:10:58 2025 -0400 from invoke commit 7ec37c2dd320d325e0cbef06ba646cbf3e557907 Author: Austin Valle Date: Mon Jul 14 08:04:32 2025 -0400 from/to plan tests commit 5d5960af32daeceaeaf78e4b83efae7424264e7a Author: Austin Valle Date: Mon Jul 14 07:24:30 2025 -0400 proto server tests commit 95e76735bd6cfa0bd2e0c2bb3c47eef2c44ec6e4 Author: Austin Valle Date: Mon Jul 14 07:12:12 2025 -0400 plan action impl and fwserver tests commit 7e5fd9f37161b70a451d21d3e9cf2b34e9d5e20d Author: Austin Valle Date: Fri Jul 11 16:04:11 2025 -0400 external interfaces for plan / configure commit 3a1048c60d18617eee24a25689d6268e523a87c8 Author: Austin Valle Date: Fri Jul 11 09:36:44 2025 -0400 fix double import commit dcd674829734d2c3bd6a1bc36eb303d78e64a081 Merge: 32b20839 4bb3f89e Author: Austin Valle Date: Fri Jul 11 08:58:38 2025 -0400 Merge branch 'main' into av/action-schema commit 32b20839418a691e4d3025b2914d6924599ec0e2 Author: Austin Valle Date: Thu Jul 10 16:55:49 2025 -0400 the rest of the tests commit 0e5df9cca30cd61fc5cea206704acf738d1da501 Author: Austin Valle Date: Thu Jul 10 16:37:34 2025 -0400 implement unlinked schemas, some attributes, and the rpcs commit 61eadc9fe7f8247b17d7e5d9f9c14ea01107c4a6 Author: Austin Valle Date: Thu Jul 10 10:40:30 2025 -0400 add initial schema attributes and unlinked schema commit 3c21a45de28a92992d3596921a01a42ae7983514 Author: Austin Valle Date: Wed Jul 9 18:07:03 2025 -0400 protov6 copy commit bf10880d2087259b3efb4aaf92cd783831fdf33a Author: Austin Valle Date: Wed Jul 9 18:02:09 2025 -0400 protov5 and fwserver impl commit 5eeac4cfb90b83dafc43157f797094e87b326100 Author: Austin Valle Date: Wed Jul 9 13:54:34 2025 -0400 generate RPC methods commit 9ec6758ee4037f810af7338265a8132430d0f77c Author: Austin Valle Date: Wed Jul 9 13:54:22 2025 -0400 go mod --- action/action.go | 30 ++ action/config_validator.go | 30 ++ action/schema/bool_attribute.go | 22 +- action/schema/bool_attribute_test.go | 33 ++ action/schema/dynamic_attribute.go | 22 +- action/schema/dynamic_attribute_test.go | 33 ++ action/schema/float32_attribute.go | 22 +- action/schema/float32_attribute_test.go | 33 ++ action/schema/float64_attribute.go | 22 +- action/schema/float64_attribute_test.go | 33 ++ action/schema/int32_attribute.go | 22 +- action/schema/int32_attribute_test.go | 33 ++ action/schema/int64_attribute.go | 22 +- action/schema/int64_attribute_test.go | 33 ++ action/schema/list_attribute.go | 20 ++ action/schema/list_attribute_test.go | 33 ++ action/schema/list_nested_attribute.go | 20 ++ action/schema/list_nested_attribute_test.go | 39 +++ action/schema/list_nested_block.go | 20 ++ action/schema/list_nested_block_test.go | 39 +++ action/schema/map_attribute.go | 20 ++ action/schema/map_attribute_test.go | 33 ++ action/schema/map_nested_attribute.go | 20 ++ action/schema/map_nested_attribute_test.go | 39 +++ action/schema/nested_attribute_object.go | 23 +- action/schema/nested_attribute_object_test.go | 37 +++ action/schema/nested_block_object.go | 23 +- action/schema/nested_block_object_test.go | 37 +++ action/schema/number_attribute.go | 22 +- action/schema/number_attribute_test.go | 33 ++ action/schema/object_attribute.go | 20 ++ action/schema/object_attribute_test.go | 33 ++ action/schema/set_attribute.go | 20 ++ action/schema/set_attribute_test.go | 33 ++ action/schema/set_nested_attribute.go | 20 ++ action/schema/set_nested_attribute_test.go | 39 +++ action/schema/set_nested_block.go | 20 ++ action/schema/set_nested_block_test.go | 39 +++ action/schema/single_nested_attribute.go | 23 +- action/schema/single_nested_attribute_test.go | 37 +++ action/schema/single_nested_block.go | 23 +- action/schema/single_nested_block_test.go | 37 +++ action/schema/string_attribute.go | 22 +- action/schema/string_attribute_test.go | 33 ++ action/validate_config.go | 33 ++ go.mod | 2 +- go.sum | 4 +- internal/fromproto5/validateactionconfig.go | 31 ++ .../fromproto5/validateactionconfig_test.go | 108 +++++++ internal/fromproto6/validateactionconfig.go | 31 ++ .../fromproto6/validateactionconfig_test.go | 108 +++++++ .../fwserver/server_validateactionconfig.go | 119 +++++++ .../server_validateactionconfig_test.go | 306 ++++++++++++++++++ .../server_validateactionconfig.go | 50 +++ .../server_validateactionconfig_test.go | 166 ++++++++++ .../server_validateactionconfig.go | 50 +++ .../server_validateactionconfig_test.go | 166 ++++++++++ .../testprovider/actionconfigvalidator.go | 47 +++ .../actionwithconfigvalidators.go | 30 ++ .../testprovider/actionwithvalidateconfig.go | 30 ++ internal/toproto5/validateactionconfig.go | 25 ++ .../toproto5/validateactionconfig_test.go | 67 ++++ internal/toproto6/validateactionconfig.go | 25 ++ .../toproto6/validateactionconfig_test.go | 67 ++++ 64 files changed, 2693 insertions(+), 19 deletions(-) create mode 100644 action/config_validator.go create mode 100644 action/validate_config.go create mode 100644 internal/fromproto5/validateactionconfig.go create mode 100644 internal/fromproto5/validateactionconfig_test.go create mode 100644 internal/fromproto6/validateactionconfig.go create mode 100644 internal/fromproto6/validateactionconfig_test.go create mode 100644 internal/fwserver/server_validateactionconfig.go create mode 100644 internal/fwserver/server_validateactionconfig_test.go create mode 100644 internal/proto5server/server_validateactionconfig.go create mode 100644 internal/proto5server/server_validateactionconfig_test.go create mode 100644 internal/proto6server/server_validateactionconfig.go create mode 100644 internal/proto6server/server_validateactionconfig_test.go create mode 100644 internal/testing/testprovider/actionconfigvalidator.go create mode 100644 internal/testing/testprovider/actionwithconfigvalidators.go create mode 100644 internal/testing/testprovider/actionwithvalidateconfig.go create mode 100644 internal/toproto5/validateactionconfig.go create mode 100644 internal/toproto5/validateactionconfig_test.go create mode 100644 internal/toproto6/validateactionconfig.go create mode 100644 internal/toproto6/validateactionconfig_test.go diff --git a/action/action.go b/action/action.go index fdec8e15b..a53b6d212 100644 --- a/action/action.go +++ b/action/action.go @@ -49,3 +49,33 @@ type ActionWithModifyPlan interface { // diagnostics to practitioners, such as validation errors. ModifyPlan(context.Context, ModifyPlanRequest, *ModifyPlanResponse) } + +// ActionWithConfigValidators is an interface type that extends Action to include declarative validations. +// +// Declaring validation using this methodology simplifies implementation of +// reusable functionality. These also include descriptions, which can be used +// for automating documentation. +// +// Validation will include ConfigValidators and ValidateConfig, if both are +// implemented, in addition to any Attribute or Type validation. +type ActionWithConfigValidators interface { + Action + + // ConfigValidators returns a list of functions which will all be performed during validation. + ConfigValidators(context.Context) []ConfigValidator +} + +// ActionWithValidateConfig is an interface type that extends Action to include imperative validation. +// +// Declaring validation using this methodology simplifies one-off +// functionality that typically applies to a single action. Any documentation +// of this functionality must be manually added into schema descriptions. +// +// Validation will include ConfigValidators and ValidateConfig, if both are +// implemented, in addition to any Attribute or Type validation. +type ActionWithValidateConfig interface { + Action + + // ValidateConfig performs the validation. + ValidateConfig(context.Context, ValidateConfigRequest, *ValidateConfigResponse) +} diff --git a/action/config_validator.go b/action/config_validator.go new file mode 100644 index 000000000..492a7d8de --- /dev/null +++ b/action/config_validator.go @@ -0,0 +1,30 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package action + +import "context" + +// ConfigValidator describes reusable Action configuration validation functionality. +type ConfigValidator interface { + // Description describes the validation in plain text formatting. + // + // This information may be automatically added to action plain text + // descriptions by external tooling. + Description(context.Context) string + + // MarkdownDescription describes the validation in Markdown formatting. + // + // This information may be automatically added to action Markdown + // descriptions by external tooling. + MarkdownDescription(context.Context) string + + // ValidateAction performs the validation. + // + // This method name is separate from the datasource.ConfigValidator + // interface ValidateDataSource method name, provider.ConfigValidator + // interface ValidateProvider method name, ephemeral.ConfigValidator + // interface ValidateEphemeralResource method name, and resource.ConfigValidator + // interface ValidateResource method name to allow generic validators. + ValidateAction(context.Context, ValidateConfigRequest, *ValidateConfigResponse) +} diff --git a/action/schema/bool_attribute.go b/action/schema/bool_attribute.go index 48c8f46db..c33070721 100644 --- a/action/schema/bool_attribute.go +++ b/action/schema/bool_attribute.go @@ -8,13 +8,16 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisfies the desired interfaces. var ( - _ Attribute = BoolAttribute{} + _ Attribute = BoolAttribute{} + _ fwxschema.AttributeWithBoolValidators = BoolAttribute{} ) // BoolAttribute represents a schema attribute that is a boolean. When @@ -89,6 +92,18 @@ type BoolAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Bool } // ApplyTerraform5AttributePathStep always returns an error as it is not @@ -167,3 +182,8 @@ func (a BoolAttribute) IsRequiredForImport() bool { func (a BoolAttribute) IsOptionalForImport() bool { return false } + +// BoolValidators returns the Validators field value. +func (a BoolAttribute) BoolValidators() []validator.Bool { + return a.Validators +} diff --git a/action/schema/bool_attribute_test.go b/action/schema/bool_attribute_test.go index 725f14c8b..a7b15890b 100644 --- a/action/schema/bool_attribute_test.go +++ b/action/schema/bool_attribute_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -435,3 +436,35 @@ func TestBoolAttributeIsOptionalForImport(t *testing.T) { }) } } + +func TestBoolAttributeBoolValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.BoolAttribute + expected []validator.Bool + }{ + "no-validators": { + attribute: schema.BoolAttribute{}, + expected: nil, + }, + "validators": { + attribute: schema.BoolAttribute{ + Validators: []validator.Bool{}, + }, + expected: []validator.Bool{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.BoolValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/action/schema/dynamic_attribute.go b/action/schema/dynamic_attribute.go index e9178f8ff..28837ef94 100644 --- a/action/schema/dynamic_attribute.go +++ b/action/schema/dynamic_attribute.go @@ -8,13 +8,16 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. var ( - _ Attribute = DynamicAttribute{} + _ Attribute = DynamicAttribute{} + _ fwxschema.AttributeWithDynamicValidators = DynamicAttribute{} ) // DynamicAttribute represents a schema attribute that is a dynamic, rather @@ -90,6 +93,18 @@ type DynamicAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Dynamic } // ApplyTerraform5AttributePathStep always returns an error as it is not @@ -168,3 +183,8 @@ func (a DynamicAttribute) IsRequiredForImport() bool { func (a DynamicAttribute) IsOptionalForImport() bool { return false } + +// DynamicValidators returns the Validators field value. +func (a DynamicAttribute) DynamicValidators() []validator.Dynamic { + return a.Validators +} diff --git a/action/schema/dynamic_attribute_test.go b/action/schema/dynamic_attribute_test.go index 7f3bb5a78..d8d6b5b31 100644 --- a/action/schema/dynamic_attribute_test.go +++ b/action/schema/dynamic_attribute_test.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -434,3 +435,35 @@ func TestDynamicAttributeIsOptionalForImport(t *testing.T) { }) } } + +func TestDynamicAttributeDynamicValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected []validator.Dynamic + }{ + "no-validators": { + attribute: schema.DynamicAttribute{}, + expected: nil, + }, + "validators": { + attribute: schema.DynamicAttribute{ + Validators: []validator.Dynamic{}, + }, + expected: []validator.Dynamic{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.DynamicValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/action/schema/float32_attribute.go b/action/schema/float32_attribute.go index e357d1338..06f998a3d 100644 --- a/action/schema/float32_attribute.go +++ b/action/schema/float32_attribute.go @@ -8,13 +8,16 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. var ( - _ Attribute = Float32Attribute{} + _ Attribute = Float32Attribute{} + _ fwxschema.AttributeWithFloat32Validators = Float32Attribute{} ) // Float32Attribute represents a schema attribute that is a 32-bit floating @@ -92,6 +95,18 @@ type Float32Attribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Float32 } // ApplyTerraform5AttributePathStep always returns an error as it is not @@ -170,3 +185,8 @@ func (a Float32Attribute) IsRequiredForImport() bool { func (a Float32Attribute) IsOptionalForImport() bool { return false } + +// Float32Validators returns the Validators field value. +func (a Float32Attribute) Float32Validators() []validator.Float32 { + return a.Validators +} diff --git a/action/schema/float32_attribute_test.go b/action/schema/float32_attribute_test.go index 1253d1611..78df1346c 100644 --- a/action/schema/float32_attribute_test.go +++ b/action/schema/float32_attribute_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -435,3 +436,35 @@ func TestFloat32AttributeIsOptionalForImport(t *testing.T) { }) } } + +func TestFloat32AttributeFloat32Validators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.Float32Attribute + expected []validator.Float32 + }{ + "no-validators": { + attribute: schema.Float32Attribute{}, + expected: nil, + }, + "validators": { + attribute: schema.Float32Attribute{ + Validators: []validator.Float32{}, + }, + expected: []validator.Float32{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.Float32Validators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/action/schema/float64_attribute.go b/action/schema/float64_attribute.go index 42ed95d0f..aad03bc59 100644 --- a/action/schema/float64_attribute.go +++ b/action/schema/float64_attribute.go @@ -8,13 +8,16 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. var ( - _ Attribute = Float64Attribute{} + _ Attribute = Float64Attribute{} + _ fwxschema.AttributeWithFloat64Validators = Float64Attribute{} ) // Float64Attribute represents a schema attribute that is a 64-bit floating @@ -92,6 +95,18 @@ type Float64Attribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Float64 } // ApplyTerraform5AttributePathStep always returns an error as it is not @@ -170,3 +185,8 @@ func (a Float64Attribute) IsRequiredForImport() bool { func (a Float64Attribute) IsOptionalForImport() bool { return false } + +// Float64Validators returns the Validators field value. +func (a Float64Attribute) Float64Validators() []validator.Float64 { + return a.Validators +} diff --git a/action/schema/float64_attribute_test.go b/action/schema/float64_attribute_test.go index f413d37c1..631565ced 100644 --- a/action/schema/float64_attribute_test.go +++ b/action/schema/float64_attribute_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -435,3 +436,35 @@ func TestFloat64AttributeIsOptionalForImport(t *testing.T) { }) } } + +func TestFloat64AttributeFloat64Validators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.Float64Attribute + expected []validator.Float64 + }{ + "no-validators": { + attribute: schema.Float64Attribute{}, + expected: nil, + }, + "validators": { + attribute: schema.Float64Attribute{ + Validators: []validator.Float64{}, + }, + expected: []validator.Float64{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.Float64Validators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/action/schema/int32_attribute.go b/action/schema/int32_attribute.go index 4bae0215d..4b57d6196 100644 --- a/action/schema/int32_attribute.go +++ b/action/schema/int32_attribute.go @@ -8,13 +8,16 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. var ( - _ Attribute = Int32Attribute{} + _ Attribute = Int32Attribute{} + _ fwxschema.AttributeWithInt32Validators = Int32Attribute{} ) // Int32Attribute represents a schema attribute that is a 32-bit integer. @@ -92,6 +95,18 @@ type Int32Attribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Int32 } // ApplyTerraform5AttributePathStep always returns an error as it is not @@ -170,3 +185,8 @@ func (a Int32Attribute) IsRequiredForImport() bool { func (a Int32Attribute) IsOptionalForImport() bool { return false } + +// Int32Validators returns the Validators field value. +func (a Int32Attribute) Int32Validators() []validator.Int32 { + return a.Validators +} diff --git a/action/schema/int32_attribute_test.go b/action/schema/int32_attribute_test.go index 9b2fac75e..88015bb5f 100644 --- a/action/schema/int32_attribute_test.go +++ b/action/schema/int32_attribute_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -435,3 +436,35 @@ func TestInt32AttributeIsOptionalForImport(t *testing.T) { }) } } + +func TestInt32AttributeInt32Validators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.Int32Attribute + expected []validator.Int32 + }{ + "no-validators": { + attribute: schema.Int32Attribute{}, + expected: nil, + }, + "validators": { + attribute: schema.Int32Attribute{ + Validators: []validator.Int32{}, + }, + expected: []validator.Int32{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.Int32Validators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/action/schema/int64_attribute.go b/action/schema/int64_attribute.go index 1b527eae4..e64f59447 100644 --- a/action/schema/int64_attribute.go +++ b/action/schema/int64_attribute.go @@ -8,13 +8,16 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. var ( - _ Attribute = Int64Attribute{} + _ Attribute = Int64Attribute{} + _ fwxschema.AttributeWithInt64Validators = Int64Attribute{} ) // Int64Attribute represents a schema attribute that is a 64-bit integer. @@ -92,6 +95,18 @@ type Int64Attribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Int64 } // ApplyTerraform5AttributePathStep always returns an error as it is not @@ -170,3 +185,8 @@ func (a Int64Attribute) IsRequiredForImport() bool { func (a Int64Attribute) IsOptionalForImport() bool { return false } + +// Int64Validators returns the Validators field value. +func (a Int64Attribute) Int64Validators() []validator.Int64 { + return a.Validators +} diff --git a/action/schema/int64_attribute_test.go b/action/schema/int64_attribute_test.go index 30376be27..9bd6cc524 100644 --- a/action/schema/int64_attribute_test.go +++ b/action/schema/int64_attribute_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -435,3 +436,35 @@ func TestInt64AttributeIsOptionalForImport(t *testing.T) { }) } } + +func TestInt64AttributeInt64Validators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.Int64Attribute + expected []validator.Int64 + }{ + "no-validators": { + attribute: schema.Int64Attribute{}, + expected: nil, + }, + "validators": { + attribute: schema.Int64Attribute{ + Validators: []validator.Int64{}, + }, + expected: []validator.Int64{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.Int64Validators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/action/schema/list_attribute.go b/action/schema/list_attribute.go index 180664e9e..86086d53e 100644 --- a/action/schema/list_attribute.go +++ b/action/schema/list_attribute.go @@ -10,7 +10,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -19,6 +21,7 @@ import ( var ( _ Attribute = ListAttribute{} _ fwschema.AttributeWithValidateImplementation = ListAttribute{} + _ fwxschema.AttributeWithListValidators = ListAttribute{} ) // ListAttribute represents a schema attribute that is a list with a single @@ -108,6 +111,18 @@ type ListAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.List } // ApplyTerraform5AttributePathStep returns the result of stepping into a list @@ -189,6 +204,11 @@ func (a ListAttribute) IsOptionalForImport() bool { return false } +// ListValidators returns the Validators field value. +func (a ListAttribute) ListValidators() []validator.List { + return a.Validators +} + // ValidateImplementation contains logic for validating the // provider-defined implementation of the attribute to prevent unexpected // errors or panics. This logic runs during the GetProviderSchema RPC and diff --git a/action/schema/list_attribute_test.go b/action/schema/list_attribute_test.go index 52d8ec503..69315448a 100644 --- a/action/schema/list_attribute_test.go +++ b/action/schema/list_attribute_test.go @@ -19,6 +19,7 @@ import ( "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/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -392,6 +393,38 @@ func TestListAttributeIsWriteOnly(t *testing.T) { } } +func TestListAttributeListValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.ListAttribute + expected []validator.List + }{ + "no-validators": { + attribute: schema.ListAttribute{ElementType: types.StringType}, + expected: nil, + }, + "validators": { + attribute: schema.ListAttribute{ + Validators: []validator.List{}, + }, + expected: []validator.List{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.ListValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestListAttributeValidateImplementation(t *testing.T) { t.Parallel() diff --git a/action/schema/list_nested_attribute.go b/action/schema/list_nested_attribute.go index ce3157ded..3287270f5 100644 --- a/action/schema/list_nested_attribute.go +++ b/action/schema/list_nested_attribute.go @@ -11,7 +11,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -20,6 +22,7 @@ import ( var ( _ NestedAttribute = ListNestedAttribute{} _ fwschema.AttributeWithValidateImplementation = ListNestedAttribute{} + _ fwxschema.AttributeWithListValidators = ListNestedAttribute{} ) // ListNestedAttribute represents an attribute that is a list of objects where @@ -118,6 +121,18 @@ type ListNestedAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.List } // ApplyTerraform5AttributePathStep returns the Attributes field value if step @@ -217,6 +232,11 @@ func (a ListNestedAttribute) IsOptionalForImport() bool { return false } +// ListValidators returns the Validators field value. +func (a ListNestedAttribute) ListValidators() []validator.List { + return a.Validators +} + // ValidateImplementation contains logic for validating the // provider-defined implementation of the attribute to prevent unexpected // errors or panics. This logic runs during the GetProviderSchema RPC and diff --git a/action/schema/list_nested_attribute_test.go b/action/schema/list_nested_attribute_test.go index 0a53508ba..e863b12d2 100644 --- a/action/schema/list_nested_attribute_test.go +++ b/action/schema/list_nested_attribute_test.go @@ -17,6 +17,7 @@ import ( "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/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -560,6 +561,44 @@ func TestListNestedAttributeIsWriteOnly(t *testing.T) { } } +func TestListNestedAttributeListValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.ListNestedAttribute + expected []validator.List + }{ + "no-validators": { + attribute: schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + }, + }, + expected: nil, + }, + "validators": { + attribute: schema.ListNestedAttribute{ + Validators: []validator.List{}, + }, + expected: []validator.List{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.ListValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestListNestedAttributeValidateImplementation(t *testing.T) { t.Parallel() diff --git a/action/schema/list_nested_block.go b/action/schema/list_nested_block.go index d61b8d888..5f3b5ca60 100644 --- a/action/schema/list_nested_block.go +++ b/action/schema/list_nested_block.go @@ -9,7 +9,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -19,6 +21,7 @@ import ( var ( _ Block = ListNestedBlock{} _ fwschema.BlockWithValidateImplementation = ListNestedBlock{} + _ fwxschema.BlockWithListValidators = ListNestedBlock{} ) // ListNestedBlock represents a block that is a list of objects where @@ -113,6 +116,18 @@ type ListNestedBlock struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.List } // ApplyTerraform5AttributePathStep returns the NestedObject field value if step @@ -173,6 +188,11 @@ func (b ListNestedBlock) Type() attr.Type { } } +// ListValidators returns the Validators field value. +func (b ListNestedBlock) ListValidators() []validator.List { + return b.Validators +} + // ValidateImplementation contains logic for validating the // provider-defined implementation of the block to prevent unexpected // errors or panics. This logic runs during the GetProviderSchema RPC and diff --git a/action/schema/list_nested_block_test.go b/action/schema/list_nested_block_test.go index d23f2614e..c80ffed3d 100644 --- a/action/schema/list_nested_block_test.go +++ b/action/schema/list_nested_block_test.go @@ -17,6 +17,7 @@ import ( "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/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -435,6 +436,44 @@ func TestListNestedBlockType(t *testing.T) { } } +func TestListNestedBlockListValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + block schema.ListNestedBlock + expected []validator.List + }{ + "no-validators": { + block: schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + }, + }, + expected: nil, + }, + "validators": { + block: schema.ListNestedBlock{ + Validators: []validator.List{}, + }, + expected: []validator.List{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.block.ListValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestListNestedBlockValidateImplementation(t *testing.T) { t.Parallel() diff --git a/action/schema/map_attribute.go b/action/schema/map_attribute.go index 80687fa09..d8d97504f 100644 --- a/action/schema/map_attribute.go +++ b/action/schema/map_attribute.go @@ -10,7 +10,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -19,6 +21,7 @@ import ( var ( _ Attribute = MapAttribute{} _ fwschema.AttributeWithValidateImplementation = MapAttribute{} + _ fwxschema.AttributeWithMapValidators = MapAttribute{} ) // MapAttribute represents a schema attribute that is a map with a single @@ -111,6 +114,18 @@ type MapAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Map } // ApplyTerraform5AttributePathStep returns the result of stepping into a map @@ -192,6 +207,11 @@ func (a MapAttribute) IsOptionalForImport() bool { return false } +// MapValidators returns the Validators field value. +func (a MapAttribute) MapValidators() []validator.Map { + return a.Validators +} + // ValidateImplementation contains logic for validating the // provider-defined implementation of the attribute to prevent unexpected // errors or panics. This logic runs during the GetProviderSchema RPC diff --git a/action/schema/map_attribute_test.go b/action/schema/map_attribute_test.go index 5e0d42947..855bf1673 100644 --- a/action/schema/map_attribute_test.go +++ b/action/schema/map_attribute_test.go @@ -17,6 +17,7 @@ import ( "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/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -391,6 +392,38 @@ func TestMapAttributeIsWriteOnly(t *testing.T) { } } +func TestMapAttributeMapValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.MapAttribute + expected []validator.Map + }{ + "no-validators": { + attribute: schema.MapAttribute{ElementType: types.StringType}, + expected: nil, + }, + "validators": { + attribute: schema.MapAttribute{ + Validators: []validator.Map{}, + }, + expected: []validator.Map{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.MapValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestMapAttributeValidateImplementation(t *testing.T) { t.Parallel() diff --git a/action/schema/map_nested_attribute.go b/action/schema/map_nested_attribute.go index 82598dc2f..a6c84303a 100644 --- a/action/schema/map_nested_attribute.go +++ b/action/schema/map_nested_attribute.go @@ -11,7 +11,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -20,6 +22,7 @@ import ( var ( _ NestedAttribute = MapNestedAttribute{} _ fwschema.AttributeWithValidateImplementation = MapNestedAttribute{} + _ fwxschema.AttributeWithMapValidators = MapNestedAttribute{} ) // MapNestedAttribute represents an attribute that is a map of objects where @@ -118,6 +121,18 @@ type MapNestedAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Map } // ApplyTerraform5AttributePathStep returns the Attributes field value if step @@ -217,6 +232,11 @@ func (a MapNestedAttribute) IsOptionalForImport() bool { return false } +// MapValidators returns the Validators field value. +func (a MapNestedAttribute) MapValidators() []validator.Map { + return a.Validators +} + // ValidateImplementation contains logic for validating the // provider-defined implementation of the attribute to prevent unexpected // errors or panics. This logic runs during the GetProviderSchema RPC and diff --git a/action/schema/map_nested_attribute_test.go b/action/schema/map_nested_attribute_test.go index 0db21ede3..bcb64fa6f 100644 --- a/action/schema/map_nested_attribute_test.go +++ b/action/schema/map_nested_attribute_test.go @@ -17,6 +17,7 @@ import ( "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/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -560,6 +561,44 @@ func TestMapNestedAttributeIsWriteOnly(t *testing.T) { } } +func TestMapNestedAttributeMapNestedValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.MapNestedAttribute + expected []validator.Map + }{ + "no-validators": { + attribute: schema.MapNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + }, + }, + expected: nil, + }, + "validators": { + attribute: schema.MapNestedAttribute{ + Validators: []validator.Map{}, + }, + expected: []validator.Map{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.MapValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestMapNestedAttributeValidateImplementation(t *testing.T) { t.Parallel() diff --git a/action/schema/nested_attribute_object.go b/action/schema/nested_attribute_object.go index b082a154f..3719a2398 100644 --- a/action/schema/nested_attribute_object.go +++ b/action/schema/nested_attribute_object.go @@ -5,14 +5,14 @@ package schema import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-go/tftypes" ) // Ensure the implementation satisifies the desired interfaces. -var ( - _ fwschema.NestedAttributeObject = NestedAttributeObject{} -) +var _ fwxschema.NestedAttributeObjectWithValidators = NestedAttributeObject{} // NestedAttributeObject is the object containing the underlying attributes // for a ListNestedAttribute, MapNestedAttribute, SetNestedAttribute, or @@ -33,6 +33,18 @@ type NestedAttributeObject struct { // default basetypes.ObjectType. When retrieving data, the basetypes.ObjectValuable // associated with this custom type must be used in place of types.Object. CustomType basetypes.ObjectTypable + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Object } // ApplyTerraform5AttributePathStep performs an AttributeName step on the @@ -55,6 +67,11 @@ func (o NestedAttributeObject) GetAttributes() fwschema.UnderlyingAttributes { return schemaAttributes(o.Attributes) } +// ObjectValidators returns the Validators field value. +func (o NestedAttributeObject) ObjectValidators() []validator.Object { + return o.Validators +} + // Type returns the framework type of the NestedAttributeObject. func (o NestedAttributeObject) Type() basetypes.ObjectTypable { if o.CustomType != nil { diff --git a/action/schema/nested_attribute_object_test.go b/action/schema/nested_attribute_object_test.go index 8c39be9e5..bde996213 100644 --- a/action/schema/nested_attribute_object_test.go +++ b/action/schema/nested_attribute_object_test.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -192,6 +193,42 @@ func TestNestedAttributeObjectGetAttributes(t *testing.T) { } } +func TestNestedAttributeObjectObjectValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.NestedAttributeObject + expected []validator.Object + }{ + "no-validators": { + attribute: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + }, + expected: nil, + }, + "validators": { + attribute: schema.NestedAttributeObject{ + Validators: []validator.Object{}, + }, + expected: []validator.Object{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.ObjectValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestNestedAttributeObjectType(t *testing.T) { t.Parallel() diff --git a/action/schema/nested_block_object.go b/action/schema/nested_block_object.go index 8193b6891..2b560b606 100644 --- a/action/schema/nested_block_object.go +++ b/action/schema/nested_block_object.go @@ -5,14 +5,14 @@ package schema import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-go/tftypes" ) // Ensure the implementation satisifies the desired interfaces. -var ( - _ fwschema.NestedBlockObject = NestedBlockObject{} -) +var _ fwxschema.NestedBlockObjectWithValidators = NestedBlockObject{} // NestedBlockObject is the object containing the underlying attributes and // blocks for a ListNestedBlock or SetNestedBlock. When retrieving the value @@ -40,6 +40,18 @@ type NestedBlockObject struct { // default basetypes.ObjectType. When retrieving data, the basetypes.ObjectValuable // associated with this custom type must be used in place of types.Object. CustomType basetypes.ObjectTypable + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Object } // ApplyTerraform5AttributePathStep performs an AttributeName step on the @@ -67,6 +79,11 @@ func (o NestedBlockObject) GetBlocks() map[string]fwschema.Block { return schemaBlocks(o.Blocks) } +// ObjectValidators returns the Validators field value. +func (o NestedBlockObject) ObjectValidators() []validator.Object { + return o.Validators +} + // Type returns the framework type of the NestedBlockObject. func (o NestedBlockObject) Type() basetypes.ObjectTypable { if o.CustomType != nil { diff --git a/action/schema/nested_block_object_test.go b/action/schema/nested_block_object_test.go index 2139384e1..f1d73ad79 100644 --- a/action/schema/nested_block_object_test.go +++ b/action/schema/nested_block_object_test.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -264,6 +265,42 @@ func TestNestedBlockObjectGetBlocks(t *testing.T) { } } +func TestNestedBlockObjectObjectValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.NestedBlockObject + expected []validator.Object + }{ + "no-validators": { + attribute: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + }, + expected: nil, + }, + "validators": { + attribute: schema.NestedBlockObject{ + Validators: []validator.Object{}, + }, + expected: []validator.Object{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.ObjectValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestNestedBlockObjectType(t *testing.T) { t.Parallel() diff --git a/action/schema/number_attribute.go b/action/schema/number_attribute.go index a4bd6cee6..672d4371d 100644 --- a/action/schema/number_attribute.go +++ b/action/schema/number_attribute.go @@ -8,13 +8,16 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. var ( - _ Attribute = NumberAttribute{} + _ Attribute = NumberAttribute{} + _ fwxschema.AttributeWithNumberValidators = NumberAttribute{} ) // NumberAttribute represents a schema attribute that is a generic number with @@ -93,6 +96,18 @@ type NumberAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Number } // ApplyTerraform5AttributePathStep always returns an error as it is not @@ -171,3 +186,8 @@ func (a NumberAttribute) IsRequiredForImport() bool { func (a NumberAttribute) IsOptionalForImport() bool { return false } + +// NumberValidators returns the Validators field value. +func (a NumberAttribute) NumberValidators() []validator.Number { + return a.Validators +} diff --git a/action/schema/number_attribute_test.go b/action/schema/number_attribute_test.go index 1ac07f668..5708b5e3f 100644 --- a/action/schema/number_attribute_test.go +++ b/action/schema/number_attribute_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -435,3 +436,35 @@ func TestNumberAttributeIsOptionalForImport(t *testing.T) { }) } } + +func TestNumberAttributeNumberValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.NumberAttribute + expected []validator.Number + }{ + "no-validators": { + attribute: schema.NumberAttribute{}, + expected: nil, + }, + "validators": { + attribute: schema.NumberAttribute{ + Validators: []validator.Number{}, + }, + expected: []validator.Number{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.NumberValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/action/schema/object_attribute.go b/action/schema/object_attribute.go index a8e54f96b..5e9c656f7 100644 --- a/action/schema/object_attribute.go +++ b/action/schema/object_attribute.go @@ -10,7 +10,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -19,6 +21,7 @@ import ( var ( _ Attribute = ObjectAttribute{} _ fwschema.AttributeWithValidateImplementation = ObjectAttribute{} + _ fwxschema.AttributeWithObjectValidators = ObjectAttribute{} ) // ObjectAttribute represents a schema attribute that is an object with only @@ -110,6 +113,18 @@ type ObjectAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Object } // ApplyTerraform5AttributePathStep returns the result of stepping into an @@ -191,6 +206,11 @@ func (a ObjectAttribute) IsOptionalForImport() bool { return false } +// ObjectValidators returns the Validators field value. +func (a ObjectAttribute) ObjectValidators() []validator.Object { + return a.Validators +} + // ValidateImplementation contains logic for validating the // provider-defined implementation of the attribute to prevent unexpected // errors or panics. This logic runs during the GetProviderSchema RPC diff --git a/action/schema/object_attribute_test.go b/action/schema/object_attribute_test.go index 1e04fdb66..4064b8ae2 100644 --- a/action/schema/object_attribute_test.go +++ b/action/schema/object_attribute_test.go @@ -19,6 +19,7 @@ import ( "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/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -398,6 +399,38 @@ func TestObjectAttributeIsWriteOnly(t *testing.T) { } } +func TestObjectAttributeObjectValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.ObjectAttribute + expected []validator.Object + }{ + "no-validators": { + attribute: schema.ObjectAttribute{AttributeTypes: map[string]attr.Type{"testattr": types.StringType}}, + expected: nil, + }, + "validators": { + attribute: schema.ObjectAttribute{ + Validators: []validator.Object{}, + }, + expected: []validator.Object{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.ObjectValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestObjectAttributeValidateImplementation(t *testing.T) { t.Parallel() diff --git a/action/schema/set_attribute.go b/action/schema/set_attribute.go index 449af04f5..eecdb6215 100644 --- a/action/schema/set_attribute.go +++ b/action/schema/set_attribute.go @@ -10,7 +10,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -19,6 +21,7 @@ import ( var ( _ Attribute = SetAttribute{} _ fwschema.AttributeWithValidateImplementation = SetAttribute{} + _ fwxschema.AttributeWithSetValidators = SetAttribute{} ) // SetAttribute represents a schema attribute that is a set with a single @@ -106,6 +109,18 @@ type SetAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Set } // ApplyTerraform5AttributePathStep returns the result of stepping into a set @@ -187,6 +202,11 @@ func (a SetAttribute) IsOptionalForImport() bool { return false } +// SetValidators returns the Validators field value. +func (a SetAttribute) SetValidators() []validator.Set { + return a.Validators +} + // ValidateImplementation contains logic for validating the // provider-defined implementation of the attribute to prevent unexpected // errors or panics. This logic runs during the GetProviderSchema RPC diff --git a/action/schema/set_attribute_test.go b/action/schema/set_attribute_test.go index 063c2b26a..73a0fa67f 100644 --- a/action/schema/set_attribute_test.go +++ b/action/schema/set_attribute_test.go @@ -17,6 +17,7 @@ import ( "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/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -391,6 +392,38 @@ func TestSetAttributeIsWriteOnly(t *testing.T) { } } +func TestSetAttributeSetValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.SetAttribute + expected []validator.Set + }{ + "no-validators": { + attribute: schema.SetAttribute{ElementType: types.StringType}, + expected: nil, + }, + "validators": { + attribute: schema.SetAttribute{ + Validators: []validator.Set{}, + }, + expected: []validator.Set{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.SetValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestSetAttributeValidateImplementation(t *testing.T) { t.Parallel() diff --git a/action/schema/set_nested_attribute.go b/action/schema/set_nested_attribute.go index 5569ee6aa..a183409d7 100644 --- a/action/schema/set_nested_attribute.go +++ b/action/schema/set_nested_attribute.go @@ -11,7 +11,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -20,6 +22,7 @@ import ( var ( _ NestedAttribute = SetNestedAttribute{} _ fwschema.AttributeWithValidateImplementation = SetNestedAttribute{} + _ fwxschema.AttributeWithSetValidators = SetNestedAttribute{} ) // SetNestedAttribute represents an attribute that is a set of objects where @@ -113,6 +116,18 @@ type SetNestedAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Set } // ApplyTerraform5AttributePathStep returns the Attributes field value if step @@ -212,6 +227,11 @@ func (a SetNestedAttribute) IsOptionalForImport() bool { return false } +// SetValidators returns the Validators field value. +func (a SetNestedAttribute) SetValidators() []validator.Set { + return a.Validators +} + // ValidateImplementation contains logic for validating the // provider-defined implementation of the attribute to prevent unexpected // errors or panics. This logic runs during the GetProviderSchema RPC and diff --git a/action/schema/set_nested_attribute_test.go b/action/schema/set_nested_attribute_test.go index 2fd2f7b44..0b5e59b28 100644 --- a/action/schema/set_nested_attribute_test.go +++ b/action/schema/set_nested_attribute_test.go @@ -19,6 +19,7 @@ import ( "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/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -561,6 +562,44 @@ func TestSetNestedAttributeIsWriteOnly(t *testing.T) { } } +func TestSetNestedAttributeSetValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.SetNestedAttribute + expected []validator.Set + }{ + "no-validators": { + attribute: schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + }, + }, + expected: nil, + }, + "validators": { + attribute: schema.SetNestedAttribute{ + Validators: []validator.Set{}, + }, + expected: []validator.Set{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.SetValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestSetNestedAttributeValidateImplementation(t *testing.T) { t.Parallel() diff --git a/action/schema/set_nested_block.go b/action/schema/set_nested_block.go index 158e43be1..e998a28c1 100644 --- a/action/schema/set_nested_block.go +++ b/action/schema/set_nested_block.go @@ -9,7 +9,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -19,6 +21,7 @@ import ( var ( _ Block = SetNestedBlock{} _ fwschema.BlockWithValidateImplementation = SetNestedBlock{} + _ fwxschema.BlockWithSetValidators = SetNestedBlock{} ) // SetNestedBlock represents a block that is a set of objects where @@ -113,6 +116,18 @@ type SetNestedBlock struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Set } // ApplyTerraform5AttributePathStep returns the NestedObject field value if step @@ -162,6 +177,11 @@ func (b SetNestedBlock) GetNestingMode() fwschema.BlockNestingMode { return fwschema.BlockNestingModeSet } +// SetValidators returns the Validators field value. +func (b SetNestedBlock) SetValidators() []validator.Set { + return b.Validators +} + // Type returns SetType of ObjectType or CustomType. func (b SetNestedBlock) Type() attr.Type { if b.CustomType != nil { diff --git a/action/schema/set_nested_block_test.go b/action/schema/set_nested_block_test.go index 5e95c6d1a..ec09d6fc9 100644 --- a/action/schema/set_nested_block_test.go +++ b/action/schema/set_nested_block_test.go @@ -17,6 +17,7 @@ import ( "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/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -379,6 +380,44 @@ func TestSetNestedBlockGetNestedObject(t *testing.T) { } } +func TestSetNestedBlockSetValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + block schema.SetNestedBlock + expected []validator.Set + }{ + "no-validators": { + block: schema.SetNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + }, + }, + expected: nil, + }, + "validators": { + block: schema.SetNestedBlock{ + Validators: []validator.Set{}, + }, + expected: []validator.Set{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.block.SetValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestSetNestedBlockType(t *testing.T) { t.Parallel() diff --git a/action/schema/single_nested_attribute.go b/action/schema/single_nested_attribute.go index 48903ab02..fcf08c1d9 100644 --- a/action/schema/single_nested_attribute.go +++ b/action/schema/single_nested_attribute.go @@ -10,13 +10,16 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. var ( - _ NestedAttribute = SingleNestedAttribute{} + _ NestedAttribute = SingleNestedAttribute{} + _ fwxschema.AttributeWithObjectValidators = SingleNestedAttribute{} ) // SingleNestedAttribute represents an attribute that is a single object where @@ -105,6 +108,18 @@ type SingleNestedAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Object } // ApplyTerraform5AttributePathStep returns the Attributes field value if step @@ -163,6 +178,7 @@ func (a SingleNestedAttribute) GetNestedObject() fwschema.NestedAttributeObject return NestedAttributeObject{ Attributes: a.Attributes, CustomType: a.CustomType, + Validators: a.Validators, } } @@ -224,3 +240,8 @@ func (a SingleNestedAttribute) IsRequiredForImport() bool { func (a SingleNestedAttribute) IsOptionalForImport() bool { return false } + +// ObjectValidators returns the Validators field value. +func (a SingleNestedAttribute) ObjectValidators() []validator.Object { + return a.Validators +} diff --git a/action/schema/single_nested_attribute_test.go b/action/schema/single_nested_attribute_test.go index a2522611d..4ceea6ca2 100644 --- a/action/schema/single_nested_attribute_test.go +++ b/action/schema/single_nested_attribute_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -573,3 +574,39 @@ func TestSingleNestedAttributeIsOptionalForImport(t *testing.T) { }) } } + +func TestSingleNestedAttributeObjectValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.SingleNestedAttribute + expected []validator.Object + }{ + "no-validators": { + attribute: schema.SingleNestedAttribute{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + }, + expected: nil, + }, + "validators": { + attribute: schema.SingleNestedAttribute{ + Validators: []validator.Object{}, + }, + expected: []validator.Object{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.ObjectValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/action/schema/single_nested_block.go b/action/schema/single_nested_block.go index feb6f63d8..af7be7939 100644 --- a/action/schema/single_nested_block.go +++ b/action/schema/single_nested_block.go @@ -8,6 +8,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -15,7 +17,8 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ Block = SingleNestedBlock{} + _ Block = SingleNestedBlock{} + _ fwxschema.BlockWithObjectValidators = SingleNestedBlock{} ) // SingleNestedBlock represents a block that is a single object where @@ -107,6 +110,18 @@ type SingleNestedBlock struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Object } // ApplyTerraform5AttributePathStep returns the Attributes field value if step @@ -161,6 +176,7 @@ func (b SingleNestedBlock) GetNestedObject() fwschema.NestedBlockObject { Attributes: b.Attributes, Blocks: b.Blocks, CustomType: b.CustomType, + Validators: b.Validators, } } @@ -169,6 +185,11 @@ func (b SingleNestedBlock) GetNestingMode() fwschema.BlockNestingMode { return fwschema.BlockNestingModeSingle } +// ObjectValidators returns the Validators field value. +func (b SingleNestedBlock) ObjectValidators() []validator.Object { + return b.Validators +} + // Type returns ObjectType or CustomType. func (b SingleNestedBlock) Type() attr.Type { if b.CustomType != nil { diff --git a/action/schema/single_nested_block_test.go b/action/schema/single_nested_block_test.go index a52addce1..3ef4d5367 100644 --- a/action/schema/single_nested_block_test.go +++ b/action/schema/single_nested_block_test.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -380,6 +381,42 @@ func TestSingleNestedBlockGetNestedObject(t *testing.T) { } } +func TestSingleNestedBlockObjectValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + block schema.SingleNestedBlock + expected []validator.Object + }{ + "no-validators": { + block: schema.SingleNestedBlock{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.StringAttribute{}, + }, + }, + expected: nil, + }, + "validators": { + block: schema.SingleNestedBlock{ + Validators: []validator.Object{}, + }, + expected: []validator.Object{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.block.ObjectValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestSingleNestedBlockType(t *testing.T) { t.Parallel() diff --git a/action/schema/string_attribute.go b/action/schema/string_attribute.go index bbf03341a..cb284d1c6 100644 --- a/action/schema/string_attribute.go +++ b/action/schema/string_attribute.go @@ -8,13 +8,16 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisfies the desired interfaces. var ( - _ Attribute = StringAttribute{} + _ Attribute = StringAttribute{} + _ fwxschema.AttributeWithStringValidators = StringAttribute{} ) // StringAttribute represents a schema attribute that is a string. When @@ -89,6 +92,18 @@ type StringAttribute struct { // - https://github.com/hashicorp/terraform/issues/7569 // DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.String } // ApplyTerraform5AttributePathStep always returns an error as it is not @@ -167,3 +182,8 @@ func (a StringAttribute) IsRequiredForImport() bool { func (a StringAttribute) IsOptionalForImport() bool { return false } + +// StringValidators returns the Validators field value. +func (a StringAttribute) StringValidators() []validator.String { + return a.Validators +} diff --git a/action/schema/string_attribute_test.go b/action/schema/string_attribute_test.go index bbfc3cfff..a930c3e16 100644 --- a/action/schema/string_attribute_test.go +++ b/action/schema/string_attribute_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -435,3 +436,35 @@ func TestStringAttributeIsOptionalForImport(t *testing.T) { }) } } + +func TestStringAttributeStringValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.StringAttribute + expected []validator.String + }{ + "no-validators": { + attribute: schema.StringAttribute{}, + expected: nil, + }, + "validators": { + attribute: schema.StringAttribute{ + Validators: []validator.String{}, + }, + expected: []validator.String{}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.StringValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/action/validate_config.go b/action/validate_config.go new file mode 100644 index 000000000..773b16fa6 --- /dev/null +++ b/action/validate_config.go @@ -0,0 +1,33 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package action + +import ( + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// ValidateConfigRequest represents a request to validate the +// configuration of an action. An instance of this request struct is +// supplied as an argument to the Action ValidateConfig receiver method +// or automatically passed through to each ConfigValidator. +type ValidateConfigRequest struct { + // Config is the configuration the user supplied for the action. + // + // This configuration may contain unknown values if a user uses + // interpolation or other functionality that would prevent Terraform + // from knowing the value at request time. + Config tfsdk.Config +} + +// ValidateConfigResponse represents a response to a +// ValidateConfigRequest. An instance of this response struct is +// supplied as an argument to the Action ValidateConfig receiver method +// or automatically passed through to each ConfigValidator. +type ValidateConfigResponse struct { + // Diagnostics report errors or warnings related to validating the action + // configuration. An empty slice indicates success, with no warnings or + // errors generated. + Diagnostics diag.Diagnostics +} diff --git a/go.mod b/go.mod index 3996d6843..eecbe544d 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.23.7 require ( github.com/google/go-cmp v0.7.0 - github.com/hashicorp/terraform-plugin-go v0.29.0-alpha.1.0.20250709165734-a8477a15f806 + github.com/hashicorp/terraform-plugin-go v0.29.0-alpha.1.0.20250717133739-e33a5336fb19 github.com/hashicorp/terraform-plugin-log v0.9.0 ) diff --git a/go.sum b/go.sum index 58d2914b5..2b4b5e573 100644 --- a/go.sum +++ b/go.sum @@ -21,8 +21,8 @@ github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0U github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/terraform-plugin-go v0.29.0-alpha.1.0.20250709165734-a8477a15f806 h1:i3kA1sT/Fk8Ex+VVKdjf9sFOPwS7w3Q73pfbnxKwdjg= -github.com/hashicorp/terraform-plugin-go v0.29.0-alpha.1.0.20250709165734-a8477a15f806/go.mod h1:hL//wLEfYo0YVt0TC/VLzia/ADQQto3HEm4/jX2gkdY= +github.com/hashicorp/terraform-plugin-go v0.29.0-alpha.1.0.20250717133739-e33a5336fb19 h1:P/ZVGEGXt9xSiLz+CrP/JzV2V8rtlE7994AX4jzcGB8= +github.com/hashicorp/terraform-plugin-go v0.29.0-alpha.1.0.20250717133739-e33a5336fb19/go.mod h1:hL//wLEfYo0YVt0TC/VLzia/ADQQto3HEm4/jX2gkdY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-registry-address v0.3.0 h1:HMpK3nqaGFPS9VmgRXrJL/dzHNdheGVKk5k7VlFxzCo= diff --git a/internal/fromproto5/validateactionconfig.go b/internal/fromproto5/validateactionconfig.go new file mode 100644 index 000000000..d43a1115f --- /dev/null +++ b/internal/fromproto5/validateactionconfig.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto5 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// ValidateActionConfigRequest returns the *fwserver.ValidateActionConfigRequest +// equivalent of a *tfprotov5.ValidateActionConfigRequest. +func ValidateActionConfigRequest(ctx context.Context, proto5 *tfprotov5.ValidateActionConfigRequest, reqAction action.Action, actionSchema fwschema.Schema) (*fwserver.ValidateActionConfigRequest, diag.Diagnostics) { + if proto5 == nil { + return nil, nil + } + + fw := &fwserver.ValidateActionConfigRequest{} + + config, diags := Config(ctx, proto5.Config, actionSchema) + + fw.Config = config + fw.Action = reqAction + + return fw, diags +} diff --git a/internal/fromproto5/validateactionconfig_test.go b/internal/fromproto5/validateactionconfig_test.go new file mode 100644 index 000000000..4ecfeedc2 --- /dev/null +++ b/internal/fromproto5/validateactionconfig_test.go @@ -0,0 +1,108 @@ +// 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/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "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/fwserver" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestValidateActionConfigRequest(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 := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test_attribute": schema.StringAttribute{ + Required: true, + }, + }, + } + + testCases := map[string]struct { + input *tfprotov5.ValidateActionConfigRequest + actionSchema fwschema.Schema + actionImpl action.Action + expected *fwserver.ValidateActionConfigRequest + expectedDiagnostics diag.Diagnostics + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &tfprotov5.ValidateActionConfigRequest{}, + expected: &fwserver.ValidateActionConfigRequest{}, + }, + "config-missing-schema": { + input: &tfprotov5.ValidateActionConfigRequest{ + Config: &testProto5DynamicValue, + }, + expected: &fwserver.ValidateActionConfigRequest{}, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Configuration", + "An unexpected error was encountered when converting the configuration 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"+ + "Missing schema.", + ), + }, + }, + "config": { + input: &tfprotov5.ValidateActionConfigRequest{ + Config: &testProto5DynamicValue, + }, + actionSchema: testFwSchema, + expected: &fwserver.ValidateActionConfigRequest{ + Config: &tfsdk.Config{ + Raw: testProto5Value, + Schema: testFwSchema, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := fromproto5.ValidateActionConfigRequest(context.Background(), testCase.input, testCase.actionImpl, testCase.actionSchema) + + 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/validateactionconfig.go b/internal/fromproto6/validateactionconfig.go new file mode 100644 index 000000000..898aabf8d --- /dev/null +++ b/internal/fromproto6/validateactionconfig.go @@ -0,0 +1,31 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto6 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// ValidateActionConfigRequest returns the *fwserver.ValidateActionConfigRequest +// equivalent of a *tfprotov6.ValidateActionConfigRequest. +func ValidateActionConfigRequest(ctx context.Context, proto6 *tfprotov6.ValidateActionConfigRequest, reqAction action.Action, actionSchema fwschema.Schema) (*fwserver.ValidateActionConfigRequest, diag.Diagnostics) { + if proto6 == nil { + return nil, nil + } + + fw := &fwserver.ValidateActionConfigRequest{} + + config, diags := Config(ctx, proto6.Config, actionSchema) + + fw.Config = config + fw.Action = reqAction + + return fw, diags +} diff --git a/internal/fromproto6/validateactionconfig_test.go b/internal/fromproto6/validateactionconfig_test.go new file mode 100644 index 000000000..c782c2e2f --- /dev/null +++ b/internal/fromproto6/validateactionconfig_test.go @@ -0,0 +1,108 @@ +// 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/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "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/fwserver" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestValidateActionConfigRequest(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 := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test_attribute": schema.StringAttribute{ + Required: true, + }, + }, + } + + testCases := map[string]struct { + input *tfprotov6.ValidateActionConfigRequest + actionSchema fwschema.Schema + actionImpl action.Action + expected *fwserver.ValidateActionConfigRequest + expectedDiagnostics diag.Diagnostics + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &tfprotov6.ValidateActionConfigRequest{}, + expected: &fwserver.ValidateActionConfigRequest{}, + }, + "config-missing-schema": { + input: &tfprotov6.ValidateActionConfigRequest{ + Config: &testProto6DynamicValue, + }, + expected: &fwserver.ValidateActionConfigRequest{}, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Configuration", + "An unexpected error was encountered when converting the configuration 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"+ + "Missing schema.", + ), + }, + }, + "config": { + input: &tfprotov6.ValidateActionConfigRequest{ + Config: &testProto6DynamicValue, + }, + actionSchema: testFwSchema, + expected: &fwserver.ValidateActionConfigRequest{ + Config: &tfsdk.Config{ + Raw: testProto6Value, + Schema: testFwSchema, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, diags := fromproto6.ValidateActionConfigRequest(context.Background(), testCase.input, testCase.actionImpl, testCase.actionSchema) + + 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/fwserver/server_validateactionconfig.go b/internal/fwserver/server_validateactionconfig.go new file mode 100644 index 000000000..5dd387681 --- /dev/null +++ b/internal/fwserver/server_validateactionconfig.go @@ -0,0 +1,119 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwserver + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/logging" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// ValidateActionConfigRequest is the framework server request for the +// ValidateActionConfig RPC. +type ValidateActionConfigRequest struct { + Config *tfsdk.Config + Action action.Action +} + +// ValidateActionConfigResponse is the framework server response for the +// ValidateActionConfig RPC. +type ValidateActionConfigResponse struct { + Diagnostics diag.Diagnostics +} + +// ValidateActionConfig implements the framework server ValidateActionConfig RPC. +func (s *Server) ValidateActionConfig(ctx context.Context, req *ValidateActionConfigRequest, resp *ValidateActionConfigResponse) { + if req == nil || req.Config == nil { + return + } + + if actionWithConfigure, ok := req.Action.(action.ActionWithConfigure); ok { + logging.FrameworkTrace(ctx, "Action implements ActionWithConfigure") + + configureReq := action.ConfigureRequest{ + ProviderData: s.ActionConfigureData, + } + configureResp := action.ConfigureResponse{} + + logging.FrameworkTrace(ctx, "Calling provider defined Action Configure") + actionWithConfigure.Configure(ctx, configureReq, &configureResp) + logging.FrameworkTrace(ctx, "Called provider defined Action Configure") + + resp.Diagnostics.Append(configureResp.Diagnostics...) + + if resp.Diagnostics.HasError() { + return + } + } + + vdscReq := action.ValidateConfigRequest{ + Config: *req.Config, + } + + if actionWithConfigValidators, ok := req.Action.(action.ActionWithConfigValidators); ok { + logging.FrameworkTrace(ctx, "Action implements ActionWithConfigValidators") + + for _, configValidator := range actionWithConfigValidators.ConfigValidators(ctx) { + // Instantiate a new response for each request to prevent validators + // from modifying or removing diagnostics. + vdscResp := &action.ValidateConfigResponse{} + + logging.FrameworkTrace( + ctx, + "Calling provider defined ActionConfigValidator", + map[string]interface{}{ + logging.KeyDescription: configValidator.Description(ctx), + }, + ) + configValidator.ValidateAction(ctx, vdscReq, vdscResp) + logging.FrameworkTrace( + ctx, + "Called provider defined ActionConfigValidator", + map[string]interface{}{ + logging.KeyDescription: configValidator.Description(ctx), + }, + ) + + resp.Diagnostics.Append(vdscResp.Diagnostics...) + } + } + + if actionWithValidateConfig, ok := req.Action.(action.ActionWithValidateConfig); ok { + logging.FrameworkTrace(ctx, "Action implements ActionWithValidateConfig") + + // Instantiate a new response for each request to prevent validators + // from modifying or removing diagnostics. + vdscResp := &action.ValidateConfigResponse{} + + logging.FrameworkTrace(ctx, "Calling provider defined Action ValidateConfig") + actionWithValidateConfig.ValidateConfig(ctx, vdscReq, vdscResp) + logging.FrameworkTrace(ctx, "Called provider defined Action ValidateConfig") + + resp.Diagnostics.Append(vdscResp.Diagnostics...) + } + + schemaCapabilities := validator.ValidateSchemaClientCapabilities{ + // The SchemaValidate function is shared between provider, resource, + // data source, ephemeral resource, and action schemas; however, WriteOnlyAttributesAllowed + // capability is only valid for resource schemas, so this is explicitly set to false + // for all other schema types. + WriteOnlyAttributesAllowed: false, + } + + validateSchemaReq := ValidateSchemaRequest{ + ClientCapabilities: schemaCapabilities, + Config: *req.Config, + } + // Instantiate a new response for each request to prevent validators + // from modifying or removing diagnostics. + validateSchemaResp := ValidateSchemaResponse{} + + SchemaValidate(ctx, req.Config.Schema, validateSchemaReq, &validateSchemaResp) + + resp.Diagnostics.Append(validateSchemaResp.Diagnostics...) +} diff --git a/internal/fwserver/server_validateactionconfig_test.go b/internal/fwserver/server_validateactionconfig_test.go new file mode 100644 index 000000000..0a9d30ce6 --- /dev/null +++ b/internal/fwserver/server_validateactionconfig_test.go @@ -0,0 +1,306 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwserver_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerValidateActionConfig(t *testing.T) { + t.Parallel() + + testType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + } + + testValue := tftypes.NewValue(testType, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }) + + testSchema := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Required: true, + }, + }, + } + + testConfig := tfsdk.Config{ + Raw: testValue, + Schema: testSchema, + } + + testSchemaAttributeValidator := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + testvalidator.String{ + ValidateStringMethod: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + if req.ConfigValue.ValueString() != "test-value" { + resp.Diagnostics.AddError("Incorrect req.AttributeConfig", "expected test-value, got "+req.ConfigValue.ValueString()) + } + }, + }, + }, + }, + }, + } + + testConfigAttributeValidator := tfsdk.Config{ + Raw: testValue, + Schema: testSchemaAttributeValidator, + } + + testSchemaAttributeValidatorError := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + testvalidator.String{ + ValidateStringMethod: func(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) { + resp.Diagnostics.AddAttributeError(req.Path, "error summary", "error detail") + }, + }, + }, + }, + }, + } + + testConfigAttributeValidatorError := tfsdk.Config{ + Raw: testValue, + Schema: testSchemaAttributeValidatorError, + } + + testCases := map[string]struct { + server *fwserver.Server + request *fwserver.ValidateActionConfigRequest + expectedResponse *fwserver.ValidateActionConfigResponse + }{ + "nil": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + expectedResponse: &fwserver.ValidateActionConfigResponse{}, + }, + "request-config": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ValidateActionConfigRequest{ + Config: &testConfig, + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testSchema + }, + }, + }, + expectedResponse: &fwserver.ValidateActionConfigResponse{}, + }, + "request-config-AttributeValidator": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ValidateActionConfigRequest{ + Config: &testConfigAttributeValidator, + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testSchemaAttributeValidator + }, + }, + }, + expectedResponse: &fwserver.ValidateActionConfigResponse{}, + }, + "request-config-AttributeValidator-diagnostic": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ValidateActionConfigRequest{ + Config: &testConfigAttributeValidatorError, + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testSchemaAttributeValidatorError + }, + }, + }, + expectedResponse: &fwserver.ValidateActionConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "error summary", + "error detail", + ), + }, + }, + }, + "request-config-ActionWithConfigValidators": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ValidateActionConfigRequest{ + Config: &testConfig, + Action: &testprovider.ActionWithConfigValidators{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testSchema + }, + }, + ConfigValidatorsMethod: func(ctx context.Context) []action.ConfigValidator { + return []action.ConfigValidator{ + &testprovider.ActionConfigValidator{ + ValidateActionMethod: func(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + var got types.String + + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("test"), &got)...) + + if resp.Diagnostics.HasError() { + return + } + + if got.ValueString() != "test-value" { + resp.Diagnostics.AddError("Incorrect req.Config", "expected test-value, got "+got.ValueString()) + } + }, + }, + } + }, + }, + }, + expectedResponse: &fwserver.ValidateActionConfigResponse{}, + }, + "request-config-ActionWithConfigValidators-diagnostics": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ValidateActionConfigRequest{ + Config: &testConfig, + Action: &testprovider.ActionWithConfigValidators{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testSchema + }, + }, + ConfigValidatorsMethod: func(ctx context.Context) []action.ConfigValidator { + return []action.ConfigValidator{ + &testprovider.ActionConfigValidator{ + ValidateActionMethod: func(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + resp.Diagnostics.AddError("error summary 1", "error detail 1") + }, + }, + &testprovider.ActionConfigValidator{ + ValidateActionMethod: func(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + // Intentionally set diagnostics instead of add/append. + // The framework should not overwrite existing diagnostics. + // Reference: https://github.com/hashicorp/terraform-plugin-framework-validators/pull/94 + resp.Diagnostics = diag.Diagnostics{ + diag.NewErrorDiagnostic("error summary 2", "error detail 2"), + } + }, + }, + } + }, + }, + }, + expectedResponse: &fwserver.ValidateActionConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "error summary 1", + "error detail 1", + ), + diag.NewErrorDiagnostic( + "error summary 2", + "error detail 2", + ), + }}, + }, + "request-config-ActionWithValidateConfig": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ValidateActionConfigRequest{ + Config: &testConfig, + Action: &testprovider.ActionWithValidateConfig{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testSchema + }, + }, + ValidateConfigMethod: func(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + var got types.String + + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("test"), &got)...) + + if resp.Diagnostics.HasError() { + return + } + + if got.ValueString() != "test-value" { + resp.Diagnostics.AddError("Incorrect req.Config", "expected test-value, got "+got.ValueString()) + } + }, + }, + }, + expectedResponse: &fwserver.ValidateActionConfigResponse{}, + }, + "request-config-ActionWithValidateConfig-diagnostic": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.ValidateActionConfigRequest{ + Config: &testConfig, + Action: &testprovider.ActionWithValidateConfig{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testSchema + }, + }, + ValidateConfigMethod: func(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + resp.Diagnostics.AddWarning("warning summary", "warning detail") + resp.Diagnostics.AddError("error summary", "error detail") + }, + }, + }, + expectedResponse: &fwserver.ValidateActionConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic( + "warning summary", + "warning detail", + ), + diag.NewErrorDiagnostic( + "error summary", + "error detail", + ), + }}, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + response := &fwserver.ValidateActionConfigResponse{} + testCase.server.ValidateActionConfig(context.Background(), testCase.request, response) + + if diff := cmp.Diff(response, testCase.expectedResponse); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/proto5server/server_validateactionconfig.go b/internal/proto5server/server_validateactionconfig.go new file mode 100644 index 000000000..296de791a --- /dev/null +++ b/internal/proto5server/server_validateactionconfig.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto5server + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/logging" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// ValidateActionConfig satisfies the tfprotov5.ProviderServer interface. +func (s *Server) ValidateActionConfig(ctx context.Context, proto5Req *tfprotov5.ValidateActionConfigRequest) (*tfprotov5.ValidateActionConfigResponse, error) { + ctx = s.registerContext(ctx) + ctx = logging.InitContext(ctx) + + fwResp := &fwserver.ValidateActionConfigResponse{} + + action, diags := s.FrameworkServer.Action(ctx, proto5Req.ActionType) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto5.ValidateActionConfigResponse(ctx, fwResp), nil + } + + actionSchema, diags := s.FrameworkServer.ActionSchema(ctx, proto5Req.ActionType) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto5.ValidateActionConfigResponse(ctx, fwResp), nil + } + + fwReq, diags := fromproto5.ValidateActionConfigRequest(ctx, proto5Req, action, actionSchema) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto5.ValidateActionConfigResponse(ctx, fwResp), nil + } + + s.FrameworkServer.ValidateActionConfig(ctx, fwReq, fwResp) + + return toproto5.ValidateActionConfigResponse(ctx, fwResp), nil +} diff --git a/internal/proto5server/server_validateactionconfig_test.go b/internal/proto5server/server_validateactionconfig_test.go new file mode 100644 index 000000000..77349fecf --- /dev/null +++ b/internal/proto5server/server_validateactionconfig_test.go @@ -0,0 +1,166 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto5server + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerValidateActionConfig(t *testing.T) { + t.Parallel() + + testType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + } + + testValue := tftypes.NewValue(testType, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }) + + testDynamicValue, err := tfprotov5.NewDynamicValue(testType, testValue) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testSchema := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Required: true, + }, + }, + } + + testCases := map[string]struct { + server *Server + request *tfprotov5.ValidateActionConfigRequest + expectedError error + expectedResponse *tfprotov5.ValidateActionConfigResponse + }{ + "no-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) {}, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.ValidateActionConfigRequest{ + ActionType: "test_action", + }, + expectedResponse: &tfprotov5.ValidateActionConfigResponse{}, + }, + "request-config": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.ValidateActionConfigRequest{ + Config: &testDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov5.ValidateActionConfigResponse{}, + }, + "response-diagnostics": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithValidateConfig{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ValidateConfigMethod: func(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + resp.Diagnostics.AddWarning("warning summary", "warning detail") + resp.Diagnostics.AddError("error summary", "error detail") + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.ValidateActionConfigRequest{ + Config: &testDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov5.ValidateActionConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.server.ValidateActionConfig(context.Background(), testCase.request) + + if diff := cmp.Diff(testCase.expectedError, err); diff != "" { + t.Errorf("unexpected error difference: %s", diff) + } + + if diff := cmp.Diff(testCase.expectedResponse, got); diff != "" { + t.Errorf("unexpected response difference: %s", diff) + } + }) + } +} diff --git a/internal/proto6server/server_validateactionconfig.go b/internal/proto6server/server_validateactionconfig.go new file mode 100644 index 000000000..994766e34 --- /dev/null +++ b/internal/proto6server/server_validateactionconfig.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto6server + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto6" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/logging" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// ValidateActionConfig satisfies the tfprotov6.ProviderServer interface. +func (s *Server) ValidateActionConfig(ctx context.Context, proto6Req *tfprotov6.ValidateActionConfigRequest) (*tfprotov6.ValidateActionConfigResponse, error) { + ctx = s.registerContext(ctx) + ctx = logging.InitContext(ctx) + + fwResp := &fwserver.ValidateActionConfigResponse{} + + action, diags := s.FrameworkServer.Action(ctx, proto6Req.ActionType) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto6.ValidateActionConfigResponse(ctx, fwResp), nil + } + + actionSchema, diags := s.FrameworkServer.ActionSchema(ctx, proto6Req.ActionType) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto6.ValidateActionConfigResponse(ctx, fwResp), nil + } + + fwReq, diags := fromproto6.ValidateActionConfigRequest(ctx, proto6Req, action, actionSchema) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto6.ValidateActionConfigResponse(ctx, fwResp), nil + } + + s.FrameworkServer.ValidateActionConfig(ctx, fwReq, fwResp) + + return toproto6.ValidateActionConfigResponse(ctx, fwResp), nil +} diff --git a/internal/proto6server/server_validateactionconfig_test.go b/internal/proto6server/server_validateactionconfig_test.go new file mode 100644 index 000000000..4fd7ce2d5 --- /dev/null +++ b/internal/proto6server/server_validateactionconfig_test.go @@ -0,0 +1,166 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto6server + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerValidateActionConfig(t *testing.T) { + t.Parallel() + + testType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + } + + testValue := tftypes.NewValue(testType, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }) + + testDynamicValue, err := tfprotov6.NewDynamicValue(testType, testValue) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testSchema := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test": schema.StringAttribute{ + Required: true, + }, + }, + } + + testCases := map[string]struct { + server *Server + request *tfprotov6.ValidateActionConfigRequest + expectedError error + expectedResponse *tfprotov6.ValidateActionConfigResponse + }{ + "no-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) {}, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.ValidateActionConfigRequest{ + ActionType: "test_action", + }, + expectedResponse: &tfprotov6.ValidateActionConfigResponse{}, + }, + "request-config": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.ValidateActionConfigRequest{ + Config: &testDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov6.ValidateActionConfigResponse{}, + }, + "response-diagnostics": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithValidateConfig{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ValidateConfigMethod: func(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + resp.Diagnostics.AddWarning("warning summary", "warning detail") + resp.Diagnostics.AddError("error summary", "error detail") + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.ValidateActionConfigRequest{ + Config: &testDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov6.ValidateActionConfigResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.server.ValidateActionConfig(context.Background(), testCase.request) + + if diff := cmp.Diff(testCase.expectedError, err); diff != "" { + t.Errorf("unexpected error difference: %s", diff) + } + + if diff := cmp.Diff(testCase.expectedResponse, got); diff != "" { + t.Errorf("unexpected response difference: %s", diff) + } + }) + } +} diff --git a/internal/testing/testprovider/actionconfigvalidator.go b/internal/testing/testprovider/actionconfigvalidator.go new file mode 100644 index 000000000..e6d3d4ade --- /dev/null +++ b/internal/testing/testprovider/actionconfigvalidator.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/action" +) + +var _ action.ConfigValidator = &ActionConfigValidator{} + +// Declarative action.ConfigValidator for unit testing. +type ActionConfigValidator struct { + // ActionConfigValidator interface methods + DescriptionMethod func(context.Context) string + MarkdownDescriptionMethod func(context.Context) string + ValidateActionMethod func(context.Context, action.ValidateConfigRequest, *action.ValidateConfigResponse) +} + +// Description satisfies the action.ConfigValidator interface. +func (v *ActionConfigValidator) Description(ctx context.Context) string { + if v.DescriptionMethod == nil { + return "" + } + + return v.DescriptionMethod(ctx) +} + +// MarkdownDescription satisfies the action.ConfigValidator interface. +func (v *ActionConfigValidator) MarkdownDescription(ctx context.Context) string { + if v.MarkdownDescriptionMethod == nil { + return "" + } + + return v.MarkdownDescriptionMethod(ctx) +} + +// Validate satisfies the action.ConfigValidator interface. +func (v *ActionConfigValidator) ValidateAction(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + if v.ValidateActionMethod == nil { + return + } + + v.ValidateActionMethod(ctx, req, resp) +} diff --git a/internal/testing/testprovider/actionwithconfigvalidators.go b/internal/testing/testprovider/actionwithconfigvalidators.go new file mode 100644 index 000000000..c3ea00233 --- /dev/null +++ b/internal/testing/testprovider/actionwithconfigvalidators.go @@ -0,0 +1,30 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/action" +) + +var _ action.Action = &ActionWithConfigValidators{} +var _ action.ActionWithConfigValidators = &ActionWithConfigValidators{} + +// Declarative action.ActionWithConfigValidators for unit testing. +type ActionWithConfigValidators struct { + *Action + + // ActionWithConfigValidators interface methods + ConfigValidatorsMethod func(context.Context) []action.ConfigValidator +} + +// ConfigValidators satisfies the action.ActionWithConfigValidators interface. +func (p *ActionWithConfigValidators) ConfigValidators(ctx context.Context) []action.ConfigValidator { + if p.ConfigValidatorsMethod == nil { + return nil + } + + return p.ConfigValidatorsMethod(ctx) +} diff --git a/internal/testing/testprovider/actionwithvalidateconfig.go b/internal/testing/testprovider/actionwithvalidateconfig.go new file mode 100644 index 000000000..62fa36850 --- /dev/null +++ b/internal/testing/testprovider/actionwithvalidateconfig.go @@ -0,0 +1,30 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/action" +) + +var _ action.Action = &ActionWithValidateConfig{} +var _ action.ActionWithValidateConfig = &ActionWithValidateConfig{} + +// Declarative action.ActionWithValidateConfig for unit testing. +type ActionWithValidateConfig struct { + *Action + + // ActionWithValidateConfig interface methods + ValidateConfigMethod func(context.Context, action.ValidateConfigRequest, *action.ValidateConfigResponse) +} + +// ValidateConfig satisfies the action.ActionWithValidateConfig interface. +func (p *ActionWithValidateConfig) ValidateConfig(ctx context.Context, req action.ValidateConfigRequest, resp *action.ValidateConfigResponse) { + if p.ValidateConfigMethod == nil { + return + } + + p.ValidateConfigMethod(ctx, req, resp) +} diff --git a/internal/toproto5/validateactionconfig.go b/internal/toproto5/validateactionconfig.go new file mode 100644 index 000000000..2d0258c8c --- /dev/null +++ b/internal/toproto5/validateactionconfig.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto5 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +// ValidateActionConfigResponse returns the *tfprotov5.ValidateActionConfigResponse +// equivalent of a *fwserver.ValidateActionConfigResponse. +func ValidateActionConfigResponse(ctx context.Context, fw *fwserver.ValidateActionConfigResponse) *tfprotov5.ValidateActionConfigResponse { + if fw == nil { + return nil + } + + proto5 := &tfprotov5.ValidateActionConfigResponse{ + Diagnostics: Diagnostics(ctx, fw.Diagnostics), + } + + return proto5 +} diff --git a/internal/toproto5/validateactionconfig_test.go b/internal/toproto5/validateactionconfig_test.go new file mode 100644 index 000000000..3f9ec5527 --- /dev/null +++ b/internal/toproto5/validateactionconfig_test.go @@ -0,0 +1,67 @@ +// 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/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +func TestValidateActionConfigResponse(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input *fwserver.ValidateActionConfigResponse + expected *tfprotov5.ValidateActionConfigResponse + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &fwserver.ValidateActionConfigResponse{}, + expected: &tfprotov5.ValidateActionConfigResponse{}, + }, + "diagnostics": { + input: &fwserver.ValidateActionConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + }, + expected: &tfprotov5.ValidateActionConfigResponse{ + 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", + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto5.ValidateActionConfigResponse(context.Background(), testCase.input) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/toproto6/validateactionconfig.go b/internal/toproto6/validateactionconfig.go new file mode 100644 index 000000000..179086cf6 --- /dev/null +++ b/internal/toproto6/validateactionconfig.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto6 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +// ValidateActionConfigResponse returns the *tfprotov6.ValidateActionConfigResponse +// equivalent of a *fwserver.ValidateActionConfigResponse. +func ValidateActionConfigResponse(ctx context.Context, fw *fwserver.ValidateActionConfigResponse) *tfprotov6.ValidateActionConfigResponse { + if fw == nil { + return nil + } + + proto6 := &tfprotov6.ValidateActionConfigResponse{ + Diagnostics: Diagnostics(ctx, fw.Diagnostics), + } + + return proto6 +} diff --git a/internal/toproto6/validateactionconfig_test.go b/internal/toproto6/validateactionconfig_test.go new file mode 100644 index 000000000..e4d3a2319 --- /dev/null +++ b/internal/toproto6/validateactionconfig_test.go @@ -0,0 +1,67 @@ +// 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/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +func TestValidateActionConfigResponse(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input *fwserver.ValidateActionConfigResponse + expected *tfprotov6.ValidateActionConfigResponse + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &fwserver.ValidateActionConfigResponse{}, + expected: &tfprotov6.ValidateActionConfigResponse{}, + }, + "diagnostics": { + input: &fwserver.ValidateActionConfigResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + }, + expected: &tfprotov6.ValidateActionConfigResponse{ + 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", + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto6.ValidateActionConfigResponse(context.Background(), testCase.input) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} From 3a3fcedb8b770d8c04c3a1407e885faec0d559dc Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 22 Jul 2025 17:33:59 -0400 Subject: [PATCH 02/18] initial implementation of linked resources --- action/modify_plan.go | 18 +- action/schema/execution_order.go | 27 +++ action/schema/lifecycle_schema.go | 178 ++++++++++++++ action/schema/linked_resource.go | 269 +++++++++++++++++++++ action/schema/schema_type.go | 3 + action/schema/unlinked_schema.go | 5 + internal/fromproto5/planaction.go | 70 +++++- internal/fromproto5/planaction_test.go | 2 +- internal/fwserver/server_planaction.go | 105 +++++++- internal/proto5server/server_planaction.go | 37 ++- internal/toproto5/action_schema.go | 12 +- internal/toproto5/planaction.go | 17 +- 12 files changed, 730 insertions(+), 13 deletions(-) create mode 100644 action/schema/execution_order.go create mode 100644 action/schema/lifecycle_schema.go create mode 100644 action/schema/linked_resource.go diff --git a/action/modify_plan.go b/action/modify_plan.go index 708656f33..a5444d8bb 100644 --- a/action/modify_plan.go +++ b/action/modify_plan.go @@ -30,13 +30,21 @@ type ModifyPlanRequest struct { // from knowing the value at request time. Config tfsdk.Config - // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented + LinkedResources []ModifyPlanRequestLinkedResource // ClientCapabilities defines optionally supported protocol features for the // PlanAction RPC, such as forward-compatible Terraform behavior changes. ClientCapabilities ModifyPlanClientCapabilities } +// TODO:Actions: docs, change name of this struct :? +type ModifyPlanRequestLinkedResource struct { + Config tfsdk.Config + State tfsdk.State + Identity *tfsdk.ResourceIdentity + Plan tfsdk.Plan +} + // ModifyPlanResponse represents a response to a // ModifyPlanRequest. An instance of this response struct is supplied // as an argument to the action's ModifyPlan function, in which the provider @@ -48,7 +56,7 @@ type ModifyPlanResponse struct { // generated. Diagnostics diag.Diagnostics - // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented + LinkedResources []ModifyPlanResponseLinkedResource // Deferred indicates that Terraform should defer planning this // action until a follow-up apply operation. @@ -60,3 +68,9 @@ type ModifyPlanResponse struct { // to change or break without warning. It is not protected by version compatibility guarantees. Deferred *Deferred } + +// TODO:Actions: docs, change name of this struct :? +type ModifyPlanResponseLinkedResource struct { + Plan tfsdk.Plan + Identity *tfsdk.ResourceIdentity +} diff --git a/action/schema/execution_order.go b/action/schema/execution_order.go new file mode 100644 index 000000000..4858d7589 --- /dev/null +++ b/action/schema/execution_order.go @@ -0,0 +1,27 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +// TODO:Actions: docs +const ( + ExecutionOrderInvalid ExecutionOrder = 0 + + ExecutionOrderBefore ExecutionOrder = 1 + + ExecutionOrderAfter ExecutionOrder = 2 +) + +type ExecutionOrder int32 + +func (d ExecutionOrder) String() string { + switch d { + case 0: + return "Invalid" + case 1: + return "Before" + case 2: + return "After" + } + return "Unknown" +} diff --git a/action/schema/lifecycle_schema.go b/action/schema/lifecycle_schema.go new file mode 100644 index 000000000..bfc454fb5 --- /dev/null +++ b/action/schema/lifecycle_schema.go @@ -0,0 +1,178 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +var _ SchemaType = LifecycleSchema{} + +// LifecycleSchema defines the structure and value types of a lifecycle action. A lifecycle action +// can cause changes to exactly resource state, defined as a linked resource. +// +// TODO:Actions: docs +type LifecycleSchema struct { + ExecutionOrder ExecutionOrder + + LinkedResource LinkedResourceType + + // Attributes is the mapping of underlying attribute names to attribute + // definitions. + // + // Names must only contain lowercase letters, numbers, and underscores. + // Names must not collide with any Blocks names. + Attributes map[string]Attribute + + // Blocks is the mapping of underlying block names to block definitions. + // + // Names must only contain lowercase letters, numbers, and underscores. + // Names must not collide with any Attributes names. + Blocks map[string]Block + + // Description is used in various tooling, like the language server, to + // give practitioners more information about what this action is, + // what it's for, and how it should be used. It should be written as + // plain text, with no special formatting. + Description string + + // MarkdownDescription is used in various tooling, like the + // documentation generator, to give practitioners more information + // about what this action is, what it's for, and how it should be + // used. It should be formatted using Markdown. + MarkdownDescription string + + // DeprecationMessage defines warning diagnostic details to display when + // practitioner configurations use this action. The warning diagnostic + // summary is automatically set to "Action Deprecated" along with + // configuration source file and line information. + // + // Set this field to a practitioner actionable message such as: + // + // - "Use examplecloud_do_thing action instead. This action + // will be removed in the next major version of the provider." + // - "Remove this action as it no longer is valid and + // will be removed in the next major version of the provider." + // + DeprecationMessage string +} + +func (s LifecycleSchema) LinkedResourceTypes() []LinkedResourceType { + return []LinkedResourceType{ + s.LinkedResource, + } +} + +func (s LifecycleSchema) isActionSchemaType() {} + +// ApplyTerraform5AttributePathStep applies the given AttributePathStep to the +// schema. +func (s LifecycleSchema) ApplyTerraform5AttributePathStep(step tftypes.AttributePathStep) (any, error) { + return fwschema.SchemaApplyTerraform5AttributePathStep(s, step) +} + +// AttributeAtPath returns the Attribute at the passed path. If the path points +// to an element or attribute of a complex type, rather than to an Attribute, +// it will return an ErrPathInsideAtomicAttribute error. +func (s LifecycleSchema) AttributeAtPath(ctx context.Context, p path.Path) (fwschema.Attribute, diag.Diagnostics) { + return fwschema.SchemaAttributeAtPath(ctx, s, p) +} + +// AttributeAtPath returns the Attribute at the passed path. If the path points +// to an element or attribute of a complex type, rather than to an Attribute, +// it will return an ErrPathInsideAtomicAttribute error. +func (s LifecycleSchema) AttributeAtTerraformPath(ctx context.Context, p *tftypes.AttributePath) (fwschema.Attribute, error) { + return fwschema.SchemaAttributeAtTerraformPath(ctx, s, p) +} + +// GetAttributes returns the Attributes field value. +func (s LifecycleSchema) GetAttributes() map[string]fwschema.Attribute { + return schemaAttributes(s.Attributes) +} + +// GetBlocks returns the Blocks field value. +func (s LifecycleSchema) GetBlocks() map[string]fwschema.Block { + return schemaBlocks(s.Blocks) +} + +// GetDeprecationMessage returns the DeprecationMessage field value. +func (s LifecycleSchema) GetDeprecationMessage() string { + return s.DeprecationMessage +} + +// GetDescription returns the Description field value. +func (s LifecycleSchema) GetDescription() string { + return s.Description +} + +// GetMarkdownDescription returns the MarkdownDescription field value. +func (s LifecycleSchema) GetMarkdownDescription() string { + return s.MarkdownDescription +} + +// GetVersion always returns 0 as action schemas cannot be versioned. +func (s LifecycleSchema) GetVersion() int64 { + return 0 +} + +// Type returns the framework type of the schema. +func (s LifecycleSchema) Type() attr.Type { + return fwschema.SchemaType(s) +} + +// TypeAtPath returns the framework type at the given schema path. +func (s LifecycleSchema) TypeAtPath(ctx context.Context, p path.Path) (attr.Type, diag.Diagnostics) { + return fwschema.SchemaTypeAtPath(ctx, s, p) +} + +// TypeAtTerraformPath returns the framework type at the given tftypes path. +func (s LifecycleSchema) TypeAtTerraformPath(ctx context.Context, p *tftypes.AttributePath) (attr.Type, error) { + return fwschema.SchemaTypeAtTerraformPath(ctx, s, p) +} + +// ValidateImplementation contains logic for validating the provider-defined +// implementation of the schema and underlying attributes and blocks to prevent +// unexpected errors or panics. This logic runs during the GetProviderSchema RPC, +// or via provider-defined unit testing, and should never include false positives. +func (s LifecycleSchema) ValidateImplementation(ctx context.Context) diag.Diagnostics { + var diags diag.Diagnostics + + // TODO:Actions: Implement validation to ensure valid lifecycle "execute" enum and linked resource definitions + + for attributeName, attribute := range s.GetAttributes() { + req := fwschema.ValidateImplementationRequest{ + Name: attributeName, + Path: path.Root(attributeName), + } + + // TODO:Actions: We should confirm with core, but we should be able to remove this next line. + // + // Action schemas define a specific "config" nested block in the action block, which means there + // shouldn't be any conflict with existing or future Terraform core attributes. + diags.Append(fwschema.IsReservedResourceAttributeName(req.Name, req.Path)...) + diags.Append(fwschema.ValidateAttributeImplementation(ctx, attribute, req)...) + } + + for blockName, block := range s.GetBlocks() { + req := fwschema.ValidateImplementationRequest{ + Name: blockName, + Path: path.Root(blockName), + } + + // TODO:Actions: We should confirm with core, but we should be able to remove this next line. + // + // Action schemas define a specific "config" nested block in the action block, which means there + // shouldn't be any conflict with existing or future Terraform core attributes. + diags.Append(fwschema.IsReservedResourceAttributeName(req.Name, req.Path)...) + diags.Append(fwschema.ValidateBlockImplementation(ctx, block, req)...) + } + + return diags +} diff --git a/action/schema/linked_resource.go b/action/schema/linked_resource.go new file mode 100644 index 000000000..b8223a9c2 --- /dev/null +++ b/action/schema/linked_resource.go @@ -0,0 +1,269 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + identityschema "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +var ( + _ LinkedResourceType = LinkedResource{} + _ RawLinkedResource = RawV5LinkedResource{} + _ RawLinkedResource = RawV6LinkedResource{} +) + +// TODO:Actions: docs +type LinkedResourceType interface { + isLinkedResourceType() + + GetTypeName() string + GetDescription() string +} + +// TODO:Actions: docs +type RawLinkedResource interface { + LinkedResourceType + + GetSchema() fwschema.Schema + GetIdentitySchema() fwschema.Schema +} + +type LinkedResources []LinkedResource + +// TODO:Actions: docs +type LinkedResource struct { + TypeName string + Description string +} + +func (l LinkedResource) isLinkedResourceType() {} + +func (l LinkedResource) GetTypeName() string { + return l.TypeName +} + +func (l LinkedResource) GetDescription() string { + return l.Description +} + +// TODO:Actions: docs +type RawV5LinkedResource struct { + TypeName string + Description string + + // TODO:Actions: It feels likely that we'd want to receive these as functions, in-case the provider schema is rather large :) + Schema *tfprotov5.Schema + IdentitySchema *tfprotov5.ResourceIdentitySchema +} + +func (l RawV5LinkedResource) isLinkedResourceType() {} + +func (l RawV5LinkedResource) GetTypeName() string { + return l.TypeName +} + +func (l RawV5LinkedResource) GetDescription() string { + return l.Description +} + +func (l RawV5LinkedResource) GetSchema() fwschema.Schema { + // TODO:Actions: This logic should probably live in an internal package, maybe fromproto + attrs := make(map[string]resourceschema.Attribute, len(l.Schema.Block.Attributes)) + for _, attr := range l.Schema.Block.Attributes { + switch { + case attr.Type.Is(tftypes.Bool): + attrs[attr.Name] = resourceschema.BoolAttribute{ + Required: attr.Required, + Optional: attr.Optional, + Computed: attr.Computed, + WriteOnly: attr.WriteOnly, + Sensitive: attr.Sensitive, + // TODO:Actions: Do we need to set more than these? Probs not. + } + case attr.Type.Is(tftypes.Number): + attrs[attr.Name] = resourceschema.NumberAttribute{ + Required: attr.Required, + Optional: attr.Optional, + Computed: attr.Computed, + WriteOnly: attr.WriteOnly, + Sensitive: attr.Sensitive, + // TODO:Actions: Do we need to set more than these? Probs not. + } + case attr.Type.Is(tftypes.String): + attrs[attr.Name] = resourceschema.StringAttribute{ + Required: attr.Required, + Optional: attr.Optional, + Computed: attr.Computed, + WriteOnly: attr.WriteOnly, + Sensitive: attr.Sensitive, + // TODO:Actions: Do we need to set more than these? Probs not. + } + // TODO:Actions: All other types (collections/structural/dynamic) + // TODO:Actions: This should essentially be the inverse of toproto schema mapping logic + } + + // TODO:Actions: Block mapping + } + return resourceschema.Schema{ + Attributes: attrs, + Blocks: map[string]resourceschema.Block{}, + // TODO:Actions: Do we need to set more than these? Probs not. + } +} + +func (l RawV5LinkedResource) GetIdentitySchema() fwschema.Schema { + // It's valid for a managed resource to not support identity, we return nil to indicate to + // other pieces of framework logic that there is no identity support for this resource. + if l.IdentitySchema == nil { + return nil + } + + // TODO:Actions: This logic should probably live in an internal package, maybe fromproto + attrs := make(map[string]identityschema.Attribute, len(l.IdentitySchema.IdentityAttributes)) + for _, attr := range l.IdentitySchema.IdentityAttributes { + switch { + case attr.Type.Is(tftypes.Bool): + attrs[attr.Name] = identityschema.BoolAttribute{ + RequiredForImport: attr.RequiredForImport, + OptionalForImport: attr.OptionalForImport, + // TODO:Actions: Do we need to set more than these? Probs not. + } + case attr.Type.Is(tftypes.Number): + attrs[attr.Name] = identityschema.NumberAttribute{ + RequiredForImport: attr.RequiredForImport, + OptionalForImport: attr.OptionalForImport, + // TODO:Actions: Do we need to set more than these? Probs not. + } + case attr.Type.Is(tftypes.String): + attrs[attr.Name] = identityschema.StringAttribute{ + RequiredForImport: attr.RequiredForImport, + OptionalForImport: attr.OptionalForImport, + // TODO:Actions: Do we need to set more than these? Probs not. + } + // TODO:Actions: All other types + // TODO:Actions: This should essentially be the inverse of toproto schema mapping logic + } + } + return identityschema.Schema{ + Attributes: attrs, + // TODO:Actions: Do we need to set more than these? Probs not. + } +} + +// TODO:Actions: docs +type RawV6LinkedResource struct { + TypeName string + Description string + + // TODO:Actions: It feels likely that we'd want to receive these as functions, in-case the provider schema is rather large :) + Schema *tfprotov6.Schema + IdentitySchema *tfprotov6.ResourceIdentitySchema +} + +func (l RawV6LinkedResource) isLinkedResourceType() {} + +func (l RawV6LinkedResource) GetTypeName() string { + return l.TypeName +} + +func (l RawV6LinkedResource) GetDescription() string { + return l.Description +} + +// TODO:Actions: Would it be invalid to use a v6 linked resource in a v5 action? My initial thought is that +// this would never happen (since the provider must all be the same protocol version at the end of the day to Terraform, +// and providers can't build actions for other providers), but I can't think of a reason why we couldn't do this? +// +// The data is all the same under the hood, but perhaps there are some validations that might break down when attempting to prevent +// setting data in nested computed attributes? :shrug: +// +// We can very easily validate this in the proto5server/proto6server in our type switch, just need to determine if that restriction is reasonable. +func (l RawV6LinkedResource) GetSchema() fwschema.Schema { + // TODO:Actions: This logic should probably live in an internal package, maybe fromproto + attrs := make(map[string]resourceschema.Attribute, len(l.Schema.Block.Attributes)) + for _, attr := range l.Schema.Block.Attributes { + switch { + case attr.Type.Is(tftypes.Bool): + attrs[attr.Name] = resourceschema.BoolAttribute{ + Required: attr.Required, + Optional: attr.Optional, + Computed: attr.Computed, + WriteOnly: attr.WriteOnly, + Sensitive: attr.Sensitive, + // TODO:Actions: Do we need to set more than these? Probs not. + } + case attr.Type.Is(tftypes.Number): + attrs[attr.Name] = resourceschema.NumberAttribute{ + Required: attr.Required, + Optional: attr.Optional, + Computed: attr.Computed, + WriteOnly: attr.WriteOnly, + Sensitive: attr.Sensitive, + // TODO:Actions: Do we need to set more than these? Probs not. + } + case attr.Type.Is(tftypes.String): + attrs[attr.Name] = resourceschema.StringAttribute{ + Required: attr.Required, + Optional: attr.Optional, + Computed: attr.Computed, + WriteOnly: attr.WriteOnly, + Sensitive: attr.Sensitive, + // TODO:Actions: Do we need to set more than these? Probs not. + } + // TODO:Actions: All other types (collections/structural/dynamic) + // TODO:Actions: This should essentially be the inverse of toproto schema mapping logic + } + + // TODO:Actions: Block mapping + } + return resourceschema.Schema{ + Attributes: attrs, + Blocks: map[string]resourceschema.Block{}, + // TODO:Actions: Do we need to set more than these? Probs not. + } +} + +func (l RawV6LinkedResource) GetIdentitySchema() fwschema.Schema { + // It's valid for a managed resource to not support identity, we return nil to indicate to + // other pieces of framework logic that there is no identity support for this resource. + if l.IdentitySchema == nil { + return nil + } + + // TODO:Actions: This logic should probably live in an internal package, maybe fromproto + attrs := make(map[string]identityschema.Attribute, len(l.IdentitySchema.IdentityAttributes)) + for _, attr := range l.IdentitySchema.IdentityAttributes { + switch { + case attr.Type.Is(tftypes.Bool): + attrs[attr.Name] = identityschema.BoolAttribute{ + RequiredForImport: attr.RequiredForImport, + OptionalForImport: attr.OptionalForImport, + // TODO:Actions: Do we need to set more than these? Probs not. + } + case attr.Type.Is(tftypes.Number): + attrs[attr.Name] = identityschema.NumberAttribute{ + RequiredForImport: attr.RequiredForImport, + OptionalForImport: attr.OptionalForImport, + // TODO:Actions: Do we need to set more than these? Probs not. + } + case attr.Type.Is(tftypes.String): + attrs[attr.Name] = identityschema.StringAttribute{ + RequiredForImport: attr.RequiredForImport, + OptionalForImport: attr.OptionalForImport, + // TODO:Actions: Do we need to set more than these? Probs not. + } + // TODO:Actions: All other types + // TODO:Actions: This should essentially be the inverse of toproto schema mapping logic + } + } + return identityschema.Schema{ + Attributes: attrs, + // TODO:Actions: Do we need to set more than these? Probs not. + } +} diff --git a/action/schema/schema_type.go b/action/schema/schema_type.go index f9c5d689a..3d3184a6c 100644 --- a/action/schema/schema_type.go +++ b/action/schema/schema_type.go @@ -38,4 +38,7 @@ type SchemaType interface { // Action schema types are statically defined in the protocol, so this // interface is not meant to be implemented outside of this package isActionSchemaType() + + // TODO:Actions: docs + LinkedResourceTypes() []LinkedResourceType } diff --git a/action/schema/unlinked_schema.go b/action/schema/unlinked_schema.go index 71af1a4f1..a6b414b0e 100644 --- a/action/schema/unlinked_schema.go +++ b/action/schema/unlinked_schema.go @@ -58,6 +58,11 @@ type UnlinkedSchema struct { DeprecationMessage string } +// LinkedResourceTypes always returns an empty slice because unlinked actions cannot support linked resources. +func (s UnlinkedSchema) LinkedResourceTypes() []LinkedResourceType { + return []LinkedResourceType{} +} + func (s UnlinkedSchema) isActionSchemaType() {} // ApplyTerraform5AttributePathStep applies the given AttributePathStep to the diff --git a/internal/fromproto5/planaction.go b/internal/fromproto5/planaction.go index b01b0d410..b1087234c 100644 --- a/internal/fromproto5/planaction.go +++ b/internal/fromproto5/planaction.go @@ -5,6 +5,7 @@ package fromproto5 import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-go/tfprotov5" @@ -12,10 +13,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) // PlanActionRequest returns the *fwserver.PlanActionRequest equivalent of a *tfprotov5.PlanActionRequest. -func PlanActionRequest(ctx context.Context, proto5 *tfprotov5.PlanActionRequest, reqAction action.Action, actionSchema fwschema.Schema) (*fwserver.PlanActionRequest, diag.Diagnostics) { +func PlanActionRequest(ctx context.Context, proto5 *tfprotov5.PlanActionRequest, reqAction action.Action, actionSchema fwschema.Schema, linkedResourceSchemas []fwschema.Schema, linkedResourceIdentitySchemas []fwschema.Schema) (*fwserver.PlanActionRequest, diag.Diagnostics) { if proto5 == nil { return nil, nil } @@ -48,7 +50,71 @@ func PlanActionRequest(ctx context.Context, proto5 *tfprotov5.PlanActionRequest, fw.Config = config - // TODO:Actions: Here we need to retrieve linked resource data + if len(proto5.LinkedResources) != len(linkedResourceSchemas) || len(proto5.LinkedResources) != len(linkedResourceIdentitySchemas) { + // TODO:Actions: Should we update this error message to include identity schemas? They should always be in sync.... + diags.AddError( + "Mismatched Linked Resources in PlanAction Request", + "An unexpected error was encountered when handling the request. "+ + "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"+ + fmt.Sprintf( + "Received %d linked resources, but the provider was expecting %d linked resources.", + len(proto5.LinkedResources), + len(linkedResourceSchemas), + ), + ) + + return nil, diags + } + + for i, linkedResource := range proto5.LinkedResources { + schema := linkedResourceSchemas[i] + identitySchema := linkedResourceIdentitySchemas[i] + + // Config + config, configDiags := Config(ctx, linkedResource.Config, schema) + diags.Append(configDiags...) + + // Prior state + priorState, priorStateDiags := State(ctx, linkedResource.PriorState, schema) + diags.Append(priorStateDiags...) + + // Planned state (plan) + plannedState, plannedStateDiags := Plan(ctx, linkedResource.PlannedState, schema) + diags.Append(plannedStateDiags...) + + // Prior identity + var priorIdentity *tfsdk.ResourceIdentity + if linkedResource.PriorIdentity != nil { + if identitySchema == nil { + // MAINTAINER NOTE: Not all linked resources support identity, so it's valid for an identity schema to be nil. However, + // it's not valid for Terraform core to send an identity for a linked resource that doesn't support one. This would likely indicate + // that there is a bug in the definition of the linked resources (not including an identity schema when it is supported), or a bug in + // either Terraform core/Framework. + // + // TODO:Actions: Update diagnostic to mention provider implementation may be incorrect? + diags.AddError( + "Unable to Convert Linked Resource Identity", + "An unexpected error was encountered when converting a linked resource identity from the protocol type. "+ + fmt.Sprintf("Identity data was sent in the protocol to an action's linked resource that doesn't support identity, index: %d.\n\n", i)+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.", + ) + return nil, diags + } + + identityVal, priorIdentityDiags := ResourceIdentity(ctx, linkedResource.PriorIdentity, identitySchema) + diags.Append(priorIdentityDiags...) + + priorIdentity = identityVal + } + + fw.LinkedResources = append(fw.LinkedResources, &fwserver.PlanLinkedResourceRequest{ + Config: config, + PlannedState: plannedState, + PriorState: priorState, + PriorIdentity: priorIdentity, + }) + } return fw, diags } diff --git a/internal/fromproto5/planaction_test.go b/internal/fromproto5/planaction_test.go index 294792d58..e2a49cbc0 100644 --- a/internal/fromproto5/planaction_test.go +++ b/internal/fromproto5/planaction_test.go @@ -130,7 +130,7 @@ func TestPlanActionRequest(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto5.PlanActionRequest(context.Background(), testCase.input, testCase.actionImpl, testCase.actionSchema) + got, diags := fromproto5.PlanActionRequest(context.Background(), testCase.input, testCase.actionImpl, testCase.actionSchema, []fwschema.Schema{}, []fwschema.Schema{}) // TODO:Actions: Temporary if diff := cmp.Diff(got, testCase.expected); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fwserver/server_planaction.go b/internal/fwserver/server_planaction.go index 4ed956e20..adb3df229 100644 --- a/internal/fwserver/server_planaction.go +++ b/internal/fwserver/server_planaction.go @@ -5,6 +5,7 @@ package fwserver import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -20,12 +21,33 @@ type PlanActionRequest struct { ActionSchema fwschema.Schema Action action.Action Config *tfsdk.Config + + // TODO:Actions: Should we introduce another layer on top of this? To protect against index-oob and prevent invalid setting of data? (depending on the action schema) + // + // Could just introduce a new tfsdk.State that is more restricted? tfsdk.LinkedResourceState? + // Theoretically, we also need the action schema itself, since there are different rules for each. + // Should we just let Terraform core handle all the validation themselves? That's how it's done today. + LinkedResources []*PlanLinkedResourceRequest // TODO:Actions: Should this be a pointer? +} + +type PlanLinkedResourceRequest struct { + Config *tfsdk.Config + PlannedState *tfsdk.Plan + PriorState *tfsdk.State + PriorIdentity *tfsdk.ResourceIdentity } // PlanActionResponse is the framework server response for the PlanAction RPC. type PlanActionResponse struct { Deferred *action.Deferred Diagnostics diag.Diagnostics + + LinkedResources []*PlanLinkedResourceResponse // TODO:Actions: Should this be a pointer? +} + +type PlanLinkedResourceResponse struct { + PlannedState *tfsdk.State + PlannedIdentity *tfsdk.ResourceIdentity } // PlanAction implements the framework server PlanAction RPC. @@ -34,9 +56,6 @@ func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *P return } - // TODO:Actions: When linked resources are introduced, pass-through proposed -> planned state similar to - // how normal resource planning works. - if s.deferred != nil { logging.FrameworkDebug(ctx, "Provider has deferred response configured, automatically returning deferred response.", map[string]interface{}{ @@ -76,16 +95,75 @@ func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *P } } + // By default, copy over planned state and identity for each linked resource + resp.LinkedResources = make([]*PlanLinkedResourceResponse, len(req.LinkedResources)) + for i, lr := range req.LinkedResources { + if lr.PlannedState == nil { + // TODO:Actions: I'm not 100% sure if this is valid enough to be a concern, PlanResourceChange populates this with a null + // value of the resource schema type, but it'd be nice to not have to carry linked resource schemas this far + // if we don't need them. + // + // My current thought is that this isn't needed (a similar check would need to be done on identity). Specifically because + // actions should always be following a linked resource PlanResourceChange call. So this value should always be populated and + // this would more be protecting future logic from panicking if a bug existing in Terraform core or Framework/SDKv2. + resp.Diagnostics.AddError( + "Invalid PlannedState for Linked Resource", + "An unexpected error was encountered when planning an action with linked resources. "+ + fmt.Sprintf("Linked resource planned state was nil when received in the protocol, index: %d.\n\n", i)+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.", + ) + return + } + + resp.LinkedResources[i] = &PlanLinkedResourceResponse{ + PlannedState: planToState(*lr.PlannedState), + } + + if lr.PriorIdentity != nil { + resp.LinkedResources[i].PlannedIdentity = &tfsdk.ResourceIdentity{ + Schema: lr.PriorIdentity.Schema, + Raw: lr.PriorIdentity.Raw.Copy(), + } + } + } + + // TODO:Actions: Should we add support for schema plan modifiers? Technically you could re-use any framework plan modifier + // implementations from the "resource/schema/planmodifier" package + if actionWithModifyPlan, ok := req.Action.(action.ActionWithModifyPlan); ok { logging.FrameworkTrace(ctx, "Action implements ActionWithModifyPlan") modifyPlanReq := action.ModifyPlanRequest{ ClientCapabilities: req.ClientCapabilities, Config: *req.Config, + LinkedResources: make([]action.ModifyPlanRequestLinkedResource, len(req.LinkedResources)), } modifyPlanResp := action.ModifyPlanResponse{ - Diagnostics: resp.Diagnostics, + Diagnostics: resp.Diagnostics, + LinkedResources: make([]action.ModifyPlanResponseLinkedResource, len(req.LinkedResources)), + } + + for i, linkedResource := range req.LinkedResources { + modifyPlanReq.LinkedResources[i] = action.ModifyPlanRequestLinkedResource{ + Config: *linkedResource.Config, + Plan: stateToPlan(*resp.LinkedResources[i].PlannedState), + State: *linkedResource.PriorState, + } + modifyPlanResp.LinkedResources[i] = action.ModifyPlanResponseLinkedResource{ + Plan: modifyPlanReq.LinkedResources[i].Plan, + } + + if resp.LinkedResources[i].PlannedIdentity != nil { + modifyPlanReq.LinkedResources[i].Identity = &tfsdk.ResourceIdentity{ + Schema: resp.LinkedResources[i].PlannedIdentity.Schema, + Raw: resp.LinkedResources[i].PlannedIdentity.Raw.Copy(), + } + modifyPlanResp.LinkedResources[i].Identity = &tfsdk.ResourceIdentity{ + Schema: resp.LinkedResources[i].PlannedIdentity.Schema, + Raw: resp.LinkedResources[i].PlannedIdentity.Raw.Copy(), + } + } } logging.FrameworkTrace(ctx, "Calling provider defined Action ModifyPlan") @@ -94,5 +172,24 @@ func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *P resp.Diagnostics = modifyPlanResp.Diagnostics resp.Deferred = modifyPlanResp.Deferred + + if len(resp.LinkedResources) != len(modifyPlanResp.LinkedResources) { + resp.Diagnostics.AddError( + "Invalid Linked Resource Plan", + "An unexpected error was encountered when planning an action with linked resources. "+ + fmt.Sprintf( + "The number of linked resources planned cannot change, expected: %d, got: %d\n\n", + len(resp.LinkedResources), + len(modifyPlanResp.LinkedResources), + )+ + "This is always a problem with the provider and should be reported to the provider developer.", + ) + return + } + + for i, newLinkedResource := range modifyPlanResp.LinkedResources { + resp.LinkedResources[i].PlannedState = planToState(newLinkedResource.Plan) + resp.LinkedResources[i].PlannedIdentity = newLinkedResource.Identity + } } } diff --git a/internal/proto5server/server_planaction.go b/internal/proto5server/server_planaction.go index 39a31ff4b..9a807106f 100644 --- a/internal/proto5server/server_planaction.go +++ b/internal/proto5server/server_planaction.go @@ -6,7 +6,9 @@ package proto5server import ( "context" + "github.com/hashicorp/terraform-plugin-framework/action/schema" "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" @@ -36,7 +38,40 @@ func (s *Server) PlanAction(ctx context.Context, proto5Req *tfprotov5.PlanAction return toproto5.PlanActionResponse(ctx, fwResp), nil } - fwReq, diags := fromproto5.PlanActionRequest(ctx, proto5Req, action, actionSchema) + lrSchemas := make([]fwschema.Schema, 0) + lrIdentitySchemas := make([]fwschema.Schema, 0) + for _, lrType := range actionSchema.LinkedResourceTypes() { + switch lrType := lrType.(type) { + case schema.RawLinkedResource: + // schema.RawLinkedResource are not on the same provider server, so we retrieve the schemas from the + // definition directly. + lrSchemas = append(lrSchemas, lrType.GetSchema()) + lrIdentitySchemas = append(lrIdentitySchemas, lrType.GetIdentitySchema()) + default: + // Any other linked resource type should be on the same provider server as the action, so we can just retrieve it + lrSchema, diags := s.FrameworkServer.ResourceSchema(ctx, lrType.GetTypeName()) + + fwResp.Diagnostics.Append(diags...) + if fwResp.Diagnostics.HasError() { + // TODO:Actions: Better error message + return toproto5.PlanActionResponse(ctx, fwResp), nil + } + + lrSchemas = append(lrSchemas, lrSchema) + + lrIdentitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, lrType.GetTypeName()) + + fwResp.Diagnostics.Append(diags...) + if fwResp.Diagnostics.HasError() { + // TODO:Actions: Better error message + return toproto5.PlanActionResponse(ctx, fwResp), nil + } + + lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) + } + } + + fwReq, diags := fromproto5.PlanActionRequest(ctx, proto5Req, action, actionSchema, lrSchemas, lrIdentitySchemas) fwResp.Diagnostics.Append(diags...) diff --git a/internal/toproto5/action_schema.go b/internal/toproto5/action_schema.go index 3b3296668..4fd06fe15 100644 --- a/internal/toproto5/action_schema.go +++ b/internal/toproto5/action_schema.go @@ -26,10 +26,18 @@ func ActionSchema(ctx context.Context, s actionschema.SchemaType) (*tfprotov5.Ac Schema: configSchema, } - // TODO:Actions: Implement linked and lifecycle action schema types - switch s.(type) { + // TODO:Actions: Implement linked action schema type + switch schema := s.(type) { case actionschema.UnlinkedSchema: result.Type = tfprotov5.UnlinkedActionSchemaType{} + case actionschema.LifecycleSchema: + result.Type = tfprotov5.LifecycleActionSchemaType{ + Executes: tfprotov5.LifecycleExecutionOrder(schema.ExecutionOrder), + LinkedResource: &tfprotov5.LinkedResourceSchema{ + TypeName: schema.LinkedResource.GetTypeName(), + Description: schema.LinkedResource.GetDescription(), + }, + } default: // It is not currently possible to create [actionschema.SchemaType] // implementations outside the "action/schema" package. If this error was reached, diff --git a/internal/toproto5/planaction.go b/internal/toproto5/planaction.go index 06f12faaf..5c56bee9e 100644 --- a/internal/toproto5/planaction.go +++ b/internal/toproto5/planaction.go @@ -22,7 +22,22 @@ func PlanActionResponse(ctx context.Context, fw *fwserver.PlanActionResponse) *t Deferred: ActionDeferred(fw.Deferred), } - // TODO:Actions: Here we need to set linked resource data + // TODO:Actions: Should we validate somewhere that no new linked resources are added to the response? I.E. the length + // matches the linked resource length in the action schema? + proto5.LinkedResources = make([]*tfprotov5.PlannedLinkedResource, len(fw.LinkedResources)) + + for i, linkedResource := range fw.LinkedResources { + plannedState, diags := State(ctx, linkedResource.PlannedState) + proto5.Diagnostics = append(proto5.Diagnostics, Diagnostics(ctx, diags)...) + + plannedIdentity, diags := ResourceIdentity(ctx, linkedResource.PlannedIdentity) + proto5.Diagnostics = append(proto5.Diagnostics, Diagnostics(ctx, diags)...) + + proto5.LinkedResources[i] = &tfprotov5.PlannedLinkedResource{ + PlannedState: plannedState, + PlannedIdentity: plannedIdentity, + } + } return proto5 } From 831006a1b9b2295040f2df9ed48bfb2ff70ab2b9 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 24 Jul 2025 15:26:21 -0400 Subject: [PATCH 03/18] quick set add --- action/schema/linked_resource.go | 101 +++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/action/schema/linked_resource.go b/action/schema/linked_resource.go index b8223a9c2..546095dbc 100644 --- a/action/schema/linked_resource.go +++ b/action/schema/linked_resource.go @@ -4,9 +4,13 @@ package schema import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" identityschema "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -106,6 +110,18 @@ func (l RawV5LinkedResource) GetSchema() fwschema.Schema { } // TODO:Actions: All other types (collections/structural/dynamic) // TODO:Actions: This should essentially be the inverse of toproto schema mapping logic + case attr.Type.Is(tftypes.Set{}): + setAttrType := attr.Type.(tftypes.Set) //nolint - asserted above + elementType, _ := tftypeToFrameworkType(setAttrType.ElementType) + + attrs[attr.Name] = resourceschema.SetAttribute{ + ElementType: elementType, + Required: attr.Required, + Optional: attr.Optional, + Computed: attr.Computed, + Sensitive: attr.Sensitive, + // TODO:Actions: Do we need to set more than these? Probs not. + } } // TODO:Actions: Block mapping @@ -117,6 +133,91 @@ func (l RawV5LinkedResource) GetSchema() fwschema.Schema { } } +// TODO: This is from the basetypes package, can probably refactor this and share +func tftypeToFrameworkType(in tftypes.Type) (attr.Type, error) { + // Primitive types + if in.Is(tftypes.Bool) { + return basetypes.BoolType{}, nil + } + if in.Is(tftypes.Number) { + return basetypes.NumberType{}, nil + } + if in.Is(tftypes.String) { + return basetypes.StringType{}, nil + } + + if in.Is(tftypes.DynamicPseudoType) { + // Null and Unknown values that do not have a type determined will have a type of DynamicPseudoType + return basetypes.DynamicType{}, nil + } + + // Collection types + if in.Is(tftypes.List{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + l := in.(tftypes.List) + + elemType, err := tftypeToFrameworkType(l.ElementType) + if err != nil { + return nil, err + } + return basetypes.ListType{ElemType: elemType}, nil + } + if in.Is(tftypes.Map{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + m := in.(tftypes.Map) + + elemType, err := tftypeToFrameworkType(m.ElementType) + if err != nil { + return nil, err + } + + return basetypes.MapType{ElemType: elemType}, nil + } + if in.Is(tftypes.Set{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + s := in.(tftypes.Set) + + elemType, err := tftypeToFrameworkType(s.ElementType) + if err != nil { + return nil, err + } + + return basetypes.SetType{ElemType: elemType}, nil + } + + // Structural types + if in.Is(tftypes.Object{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + o := in.(tftypes.Object) + + attrTypes := make(map[string]attr.Type, len(o.AttributeTypes)) + for name, tfType := range o.AttributeTypes { + t, err := tftypeToFrameworkType(tfType) + if err != nil { + return nil, err + } + attrTypes[name] = t + } + return basetypes.ObjectType{AttrTypes: attrTypes}, nil + } + if in.Is(tftypes.Tuple{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + tup := in.(tftypes.Tuple) + + elemTypes := make([]attr.Type, len(tup.ElementTypes)) + for i, tfType := range tup.ElementTypes { + t, err := tftypeToFrameworkType(tfType) + if err != nil { + return nil, err + } + elemTypes[i] = t + } + return basetypes.TupleType{ElemTypes: elemTypes}, nil + } + + return nil, fmt.Errorf("unsupported tftypes.Type detected: %T", in) +} + func (l RawV5LinkedResource) GetIdentitySchema() fwschema.Schema { // It's valid for a managed resource to not support identity, we return nil to indicate to // other pieces of framework logic that there is no identity support for this resource. From b3515eb47b02bc8728f0529821879d1e78936635 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 28 Jul 2025 17:16:33 -0400 Subject: [PATCH 04/18] fix tests with extra nil check --- action/schema/unlinked_schema_test.go | 2 +- datasource/schema/schema_test.go | 2 +- ephemeral/schema/schema_test.go | 2 +- internal/toproto5/block_test.go | 2 +- internal/toproto5/identity_schema_attribute_test.go | 2 +- internal/toproto5/identity_schema_test.go | 2 +- internal/toproto5/schema_attribute_test.go | 2 +- internal/toproto5/schema_test.go | 2 +- internal/toproto6/block_test.go | 2 +- internal/toproto6/identity_schema_attribute_test.go | 2 +- internal/toproto6/identity_schema_test.go | 2 +- internal/toproto6/schema_attribute_test.go | 2 +- internal/toproto6/schema_test.go | 2 +- list/schema/schema_test.go | 2 +- provider/metaschema/schema_test.go | 2 +- provider/schema/schema_test.go | 2 +- resource/identityschema/schema_test.go | 2 +- resource/schema/schema_test.go | 2 +- types/basetypes/bool_type_test.go | 2 +- types/basetypes/float32_type_test.go | 2 +- types/basetypes/float64_type_test.go | 2 +- types/basetypes/int32_type_test.go | 2 +- types/basetypes/int64_type_test.go | 2 +- types/basetypes/number_type_test.go | 2 +- types/basetypes/string_type_test.go | 2 +- 25 files changed, 25 insertions(+), 25 deletions(-) diff --git a/action/schema/unlinked_schema_test.go b/action/schema/unlinked_schema_test.go index 1af3164f7..881e1d6a3 100644 --- a/action/schema/unlinked_schema_test.go +++ b/action/schema/unlinked_schema_test.go @@ -406,7 +406,7 @@ func TestSchemaAttributeAtTerraformPath(t *testing.T) { return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/datasource/schema/schema_test.go b/datasource/schema/schema_test.go index a6eb8ee5c..36bbd47c4 100644 --- a/datasource/schema/schema_test.go +++ b/datasource/schema/schema_test.go @@ -406,7 +406,7 @@ func TestSchemaAttributeAtTerraformPath(t *testing.T) { return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/ephemeral/schema/schema_test.go b/ephemeral/schema/schema_test.go index e08d76a5d..d6212aff3 100644 --- a/ephemeral/schema/schema_test.go +++ b/ephemeral/schema/schema_test.go @@ -406,7 +406,7 @@ func TestSchemaAttributeAtTerraformPath(t *testing.T) { return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/internal/toproto5/block_test.go b/internal/toproto5/block_test.go index 303474e56..04c879ec4 100644 --- a/internal/toproto5/block_test.go +++ b/internal/toproto5/block_test.go @@ -567,7 +567,7 @@ func TestBlock(t *testing.T) { // got expected error return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/internal/toproto5/identity_schema_attribute_test.go b/internal/toproto5/identity_schema_attribute_test.go index 28704ba5d..29e651820 100644 --- a/internal/toproto5/identity_schema_attribute_test.go +++ b/internal/toproto5/identity_schema_attribute_test.go @@ -244,7 +244,7 @@ func TestIdentitySchemaAttribute(t *testing.T) { // got expected error return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/internal/toproto5/identity_schema_test.go b/internal/toproto5/identity_schema_test.go index 8388c4c16..5d867f685 100644 --- a/internal/toproto5/identity_schema_test.go +++ b/internal/toproto5/identity_schema_test.go @@ -125,7 +125,7 @@ func TestIdentitySchema(t *testing.T) { // got expected error return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/internal/toproto5/schema_attribute_test.go b/internal/toproto5/schema_attribute_test.go index cc52ebd92..1d754ef4f 100644 --- a/internal/toproto5/schema_attribute_test.go +++ b/internal/toproto5/schema_attribute_test.go @@ -385,7 +385,7 @@ func TestSchemaAttribute(t *testing.T) { // got expected error return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/internal/toproto5/schema_test.go b/internal/toproto5/schema_test.go index 587f0424c..6f6fe3b76 100644 --- a/internal/toproto5/schema_test.go +++ b/internal/toproto5/schema_test.go @@ -539,7 +539,7 @@ func TestSchema(t *testing.T) { // got expected error return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/internal/toproto6/block_test.go b/internal/toproto6/block_test.go index e1c5784d3..7ccdcc315 100644 --- a/internal/toproto6/block_test.go +++ b/internal/toproto6/block_test.go @@ -567,7 +567,7 @@ func TestBlock(t *testing.T) { // got expected error return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/internal/toproto6/identity_schema_attribute_test.go b/internal/toproto6/identity_schema_attribute_test.go index 45fa0d1a1..af59bda08 100644 --- a/internal/toproto6/identity_schema_attribute_test.go +++ b/internal/toproto6/identity_schema_attribute_test.go @@ -244,7 +244,7 @@ func TestIdentitySchemaAttribute(t *testing.T) { // got expected error return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/internal/toproto6/identity_schema_test.go b/internal/toproto6/identity_schema_test.go index f8ebd1728..7759c7034 100644 --- a/internal/toproto6/identity_schema_test.go +++ b/internal/toproto6/identity_schema_test.go @@ -125,7 +125,7 @@ func TestIdentitySchema(t *testing.T) { // got expected error return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/internal/toproto6/schema_attribute_test.go b/internal/toproto6/schema_attribute_test.go index 1eb83c0c0..eb8ffd907 100644 --- a/internal/toproto6/schema_attribute_test.go +++ b/internal/toproto6/schema_attribute_test.go @@ -453,7 +453,7 @@ func TestSchemaAttribute(t *testing.T) { // got expected error return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/internal/toproto6/schema_test.go b/internal/toproto6/schema_test.go index 50d203c61..c2ed9e3be 100644 --- a/internal/toproto6/schema_test.go +++ b/internal/toproto6/schema_test.go @@ -643,7 +643,7 @@ func TestSchema(t *testing.T) { // got expected error return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/list/schema/schema_test.go b/list/schema/schema_test.go index b556ac7df..4ae7f04c2 100644 --- a/list/schema/schema_test.go +++ b/list/schema/schema_test.go @@ -339,7 +339,7 @@ func TestSchemaAttributeAtTerraformPath(t *testing.T) { return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/provider/metaschema/schema_test.go b/provider/metaschema/schema_test.go index 7460f8016..426c6f819 100644 --- a/provider/metaschema/schema_test.go +++ b/provider/metaschema/schema_test.go @@ -341,7 +341,7 @@ func TestSchemaAttributeAtTerraformPath(t *testing.T) { return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/provider/schema/schema_test.go b/provider/schema/schema_test.go index 9007eabdc..ddcba42c2 100644 --- a/provider/schema/schema_test.go +++ b/provider/schema/schema_test.go @@ -406,7 +406,7 @@ func TestSchemaAttributeAtTerraformPath(t *testing.T) { return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/resource/identityschema/schema_test.go b/resource/identityschema/schema_test.go index 394cdd426..248612a6c 100644 --- a/resource/identityschema/schema_test.go +++ b/resource/identityschema/schema_test.go @@ -341,7 +341,7 @@ func TestSchemaAttributeAtTerraformPath(t *testing.T) { return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/resource/schema/schema_test.go b/resource/schema/schema_test.go index a48fed812..c8de116bb 100644 --- a/resource/schema/schema_test.go +++ b/resource/schema/schema_test.go @@ -406,7 +406,7 @@ func TestSchemaAttributeAtTerraformPath(t *testing.T) { return } - if err == nil && tc.expectedErr != "" { + if tc.expectedErr != "" { t.Errorf("Expected error to be %q, got nil", tc.expectedErr) return } diff --git a/types/basetypes/bool_type_test.go b/types/basetypes/bool_type_test.go index 2280dcfa3..1a89cb92c 100644 --- a/types/basetypes/bool_type_test.go +++ b/types/basetypes/bool_type_test.go @@ -60,7 +60,7 @@ func TestBoolTypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } diff --git a/types/basetypes/float32_type_test.go b/types/basetypes/float32_type_test.go index 49ff4960d..c2d3c8354 100644 --- a/types/basetypes/float32_type_test.go +++ b/types/basetypes/float32_type_test.go @@ -119,7 +119,7 @@ func TestFloat32TypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } diff --git a/types/basetypes/float64_type_test.go b/types/basetypes/float64_type_test.go index 2e86204d9..271899492 100644 --- a/types/basetypes/float64_type_test.go +++ b/types/basetypes/float64_type_test.go @@ -205,7 +205,7 @@ func TestFloat64TypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } diff --git a/types/basetypes/int32_type_test.go b/types/basetypes/int32_type_test.go index f47ddd803..9d7e128f6 100644 --- a/types/basetypes/int32_type_test.go +++ b/types/basetypes/int32_type_test.go @@ -78,7 +78,7 @@ func TestInt32TypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } diff --git a/types/basetypes/int64_type_test.go b/types/basetypes/int64_type_test.go index 6eb115bc9..06ed80cf4 100644 --- a/types/basetypes/int64_type_test.go +++ b/types/basetypes/int64_type_test.go @@ -56,7 +56,7 @@ func TestInt64TypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } diff --git a/types/basetypes/number_type_test.go b/types/basetypes/number_type_test.go index 153e8f9c2..7a5654b75 100644 --- a/types/basetypes/number_type_test.go +++ b/types/basetypes/number_type_test.go @@ -57,7 +57,7 @@ func TestNumberTypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } diff --git a/types/basetypes/string_type_test.go b/types/basetypes/string_type_test.go index bcaf9e6d1..f1c590dc3 100644 --- a/types/basetypes/string_type_test.go +++ b/types/basetypes/string_type_test.go @@ -56,7 +56,7 @@ func TestStringTypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } From 4ae7a31e0f30494c33e6d8e0c0270d2290301c12 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 30 Jul 2025 09:45:54 -0400 Subject: [PATCH 05/18] commit non-working --- action/schema/linked_resource.go | 314 +----------------- internal/fromproto5/resource_schema.go | 203 +++++++++++ internal/fromproto5/resource_schema_test.go | 233 +++++++++++++ internal/proto5server/server_planaction.go | 26 +- types/basetypes/dynamic_type.go | 90 +---- .../terraform_type_to_framework_type.go | 95 ++++++ 6 files changed, 565 insertions(+), 396 deletions(-) create mode 100644 internal/fromproto5/resource_schema.go create mode 100644 internal/fromproto5/resource_schema_test.go create mode 100644 types/basetypes/terraform_type_to_framework_type.go diff --git a/action/schema/linked_resource.go b/action/schema/linked_resource.go index 546095dbc..fcc3722ec 100644 --- a/action/schema/linked_resource.go +++ b/action/schema/linked_resource.go @@ -4,22 +4,14 @@ package schema import ( - "fmt" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" - identityschema "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" - resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tfprotov6" - "github.com/hashicorp/terraform-plugin-go/tftypes" ) var ( _ LinkedResourceType = LinkedResource{} - _ RawLinkedResource = RawV5LinkedResource{} - _ RawLinkedResource = RawV6LinkedResource{} + _ LinkedResourceType = RawV5LinkedResource{} + _ LinkedResourceType = RawV6LinkedResource{} ) // TODO:Actions: docs @@ -29,15 +21,6 @@ type LinkedResourceType interface { GetTypeName() string GetDescription() string } - -// TODO:Actions: docs -type RawLinkedResource interface { - LinkedResourceType - - GetSchema() fwschema.Schema - GetIdentitySchema() fwschema.Schema -} - type LinkedResources []LinkedResource // TODO:Actions: docs @@ -58,12 +41,10 @@ func (l LinkedResource) GetDescription() string { // TODO:Actions: docs type RawV5LinkedResource struct { - TypeName string - Description string - - // TODO:Actions: It feels likely that we'd want to receive these as functions, in-case the provider schema is rather large :) - Schema *tfprotov5.Schema - IdentitySchema *tfprotov5.ResourceIdentitySchema + TypeName string + Description string + Schema func() *tfprotov5.Schema + IdentitySchema func() *tfprotov5.ResourceIdentitySchema } func (l RawV5LinkedResource) isLinkedResourceType() {} @@ -76,195 +57,12 @@ func (l RawV5LinkedResource) GetDescription() string { return l.Description } -func (l RawV5LinkedResource) GetSchema() fwschema.Schema { - // TODO:Actions: This logic should probably live in an internal package, maybe fromproto - attrs := make(map[string]resourceschema.Attribute, len(l.Schema.Block.Attributes)) - for _, attr := range l.Schema.Block.Attributes { - switch { - case attr.Type.Is(tftypes.Bool): - attrs[attr.Name] = resourceschema.BoolAttribute{ - Required: attr.Required, - Optional: attr.Optional, - Computed: attr.Computed, - WriteOnly: attr.WriteOnly, - Sensitive: attr.Sensitive, - // TODO:Actions: Do we need to set more than these? Probs not. - } - case attr.Type.Is(tftypes.Number): - attrs[attr.Name] = resourceschema.NumberAttribute{ - Required: attr.Required, - Optional: attr.Optional, - Computed: attr.Computed, - WriteOnly: attr.WriteOnly, - Sensitive: attr.Sensitive, - // TODO:Actions: Do we need to set more than these? Probs not. - } - case attr.Type.Is(tftypes.String): - attrs[attr.Name] = resourceschema.StringAttribute{ - Required: attr.Required, - Optional: attr.Optional, - Computed: attr.Computed, - WriteOnly: attr.WriteOnly, - Sensitive: attr.Sensitive, - // TODO:Actions: Do we need to set more than these? Probs not. - } - // TODO:Actions: All other types (collections/structural/dynamic) - // TODO:Actions: This should essentially be the inverse of toproto schema mapping logic - case attr.Type.Is(tftypes.Set{}): - setAttrType := attr.Type.(tftypes.Set) //nolint - asserted above - elementType, _ := tftypeToFrameworkType(setAttrType.ElementType) - - attrs[attr.Name] = resourceschema.SetAttribute{ - ElementType: elementType, - Required: attr.Required, - Optional: attr.Optional, - Computed: attr.Computed, - Sensitive: attr.Sensitive, - // TODO:Actions: Do we need to set more than these? Probs not. - } - } - - // TODO:Actions: Block mapping - } - return resourceschema.Schema{ - Attributes: attrs, - Blocks: map[string]resourceschema.Block{}, - // TODO:Actions: Do we need to set more than these? Probs not. - } -} - -// TODO: This is from the basetypes package, can probably refactor this and share -func tftypeToFrameworkType(in tftypes.Type) (attr.Type, error) { - // Primitive types - if in.Is(tftypes.Bool) { - return basetypes.BoolType{}, nil - } - if in.Is(tftypes.Number) { - return basetypes.NumberType{}, nil - } - if in.Is(tftypes.String) { - return basetypes.StringType{}, nil - } - - if in.Is(tftypes.DynamicPseudoType) { - // Null and Unknown values that do not have a type determined will have a type of DynamicPseudoType - return basetypes.DynamicType{}, nil - } - - // Collection types - if in.Is(tftypes.List{}) { - //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function - l := in.(tftypes.List) - - elemType, err := tftypeToFrameworkType(l.ElementType) - if err != nil { - return nil, err - } - return basetypes.ListType{ElemType: elemType}, nil - } - if in.Is(tftypes.Map{}) { - //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function - m := in.(tftypes.Map) - - elemType, err := tftypeToFrameworkType(m.ElementType) - if err != nil { - return nil, err - } - - return basetypes.MapType{ElemType: elemType}, nil - } - if in.Is(tftypes.Set{}) { - //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function - s := in.(tftypes.Set) - - elemType, err := tftypeToFrameworkType(s.ElementType) - if err != nil { - return nil, err - } - - return basetypes.SetType{ElemType: elemType}, nil - } - - // Structural types - if in.Is(tftypes.Object{}) { - //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function - o := in.(tftypes.Object) - - attrTypes := make(map[string]attr.Type, len(o.AttributeTypes)) - for name, tfType := range o.AttributeTypes { - t, err := tftypeToFrameworkType(tfType) - if err != nil { - return nil, err - } - attrTypes[name] = t - } - return basetypes.ObjectType{AttrTypes: attrTypes}, nil - } - if in.Is(tftypes.Tuple{}) { - //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function - tup := in.(tftypes.Tuple) - - elemTypes := make([]attr.Type, len(tup.ElementTypes)) - for i, tfType := range tup.ElementTypes { - t, err := tftypeToFrameworkType(tfType) - if err != nil { - return nil, err - } - elemTypes[i] = t - } - return basetypes.TupleType{ElemTypes: elemTypes}, nil - } - - return nil, fmt.Errorf("unsupported tftypes.Type detected: %T", in) -} - -func (l RawV5LinkedResource) GetIdentitySchema() fwschema.Schema { - // It's valid for a managed resource to not support identity, we return nil to indicate to - // other pieces of framework logic that there is no identity support for this resource. - if l.IdentitySchema == nil { - return nil - } - - // TODO:Actions: This logic should probably live in an internal package, maybe fromproto - attrs := make(map[string]identityschema.Attribute, len(l.IdentitySchema.IdentityAttributes)) - for _, attr := range l.IdentitySchema.IdentityAttributes { - switch { - case attr.Type.Is(tftypes.Bool): - attrs[attr.Name] = identityschema.BoolAttribute{ - RequiredForImport: attr.RequiredForImport, - OptionalForImport: attr.OptionalForImport, - // TODO:Actions: Do we need to set more than these? Probs not. - } - case attr.Type.Is(tftypes.Number): - attrs[attr.Name] = identityschema.NumberAttribute{ - RequiredForImport: attr.RequiredForImport, - OptionalForImport: attr.OptionalForImport, - // TODO:Actions: Do we need to set more than these? Probs not. - } - case attr.Type.Is(tftypes.String): - attrs[attr.Name] = identityschema.StringAttribute{ - RequiredForImport: attr.RequiredForImport, - OptionalForImport: attr.OptionalForImport, - // TODO:Actions: Do we need to set more than these? Probs not. - } - // TODO:Actions: All other types - // TODO:Actions: This should essentially be the inverse of toproto schema mapping logic - } - } - return identityschema.Schema{ - Attributes: attrs, - // TODO:Actions: Do we need to set more than these? Probs not. - } -} - // TODO:Actions: docs type RawV6LinkedResource struct { - TypeName string - Description string - - // TODO:Actions: It feels likely that we'd want to receive these as functions, in-case the provider schema is rather large :) - Schema *tfprotov6.Schema - IdentitySchema *tfprotov6.ResourceIdentitySchema + TypeName string + Description string + Schema func() *tfprotov6.Schema + IdentitySchema func() *tfprotov6.ResourceIdentitySchema } func (l RawV6LinkedResource) isLinkedResourceType() {} @@ -276,95 +74,3 @@ func (l RawV6LinkedResource) GetTypeName() string { func (l RawV6LinkedResource) GetDescription() string { return l.Description } - -// TODO:Actions: Would it be invalid to use a v6 linked resource in a v5 action? My initial thought is that -// this would never happen (since the provider must all be the same protocol version at the end of the day to Terraform, -// and providers can't build actions for other providers), but I can't think of a reason why we couldn't do this? -// -// The data is all the same under the hood, but perhaps there are some validations that might break down when attempting to prevent -// setting data in nested computed attributes? :shrug: -// -// We can very easily validate this in the proto5server/proto6server in our type switch, just need to determine if that restriction is reasonable. -func (l RawV6LinkedResource) GetSchema() fwschema.Schema { - // TODO:Actions: This logic should probably live in an internal package, maybe fromproto - attrs := make(map[string]resourceschema.Attribute, len(l.Schema.Block.Attributes)) - for _, attr := range l.Schema.Block.Attributes { - switch { - case attr.Type.Is(tftypes.Bool): - attrs[attr.Name] = resourceschema.BoolAttribute{ - Required: attr.Required, - Optional: attr.Optional, - Computed: attr.Computed, - WriteOnly: attr.WriteOnly, - Sensitive: attr.Sensitive, - // TODO:Actions: Do we need to set more than these? Probs not. - } - case attr.Type.Is(tftypes.Number): - attrs[attr.Name] = resourceschema.NumberAttribute{ - Required: attr.Required, - Optional: attr.Optional, - Computed: attr.Computed, - WriteOnly: attr.WriteOnly, - Sensitive: attr.Sensitive, - // TODO:Actions: Do we need to set more than these? Probs not. - } - case attr.Type.Is(tftypes.String): - attrs[attr.Name] = resourceschema.StringAttribute{ - Required: attr.Required, - Optional: attr.Optional, - Computed: attr.Computed, - WriteOnly: attr.WriteOnly, - Sensitive: attr.Sensitive, - // TODO:Actions: Do we need to set more than these? Probs not. - } - // TODO:Actions: All other types (collections/structural/dynamic) - // TODO:Actions: This should essentially be the inverse of toproto schema mapping logic - } - - // TODO:Actions: Block mapping - } - return resourceschema.Schema{ - Attributes: attrs, - Blocks: map[string]resourceschema.Block{}, - // TODO:Actions: Do we need to set more than these? Probs not. - } -} - -func (l RawV6LinkedResource) GetIdentitySchema() fwschema.Schema { - // It's valid for a managed resource to not support identity, we return nil to indicate to - // other pieces of framework logic that there is no identity support for this resource. - if l.IdentitySchema == nil { - return nil - } - - // TODO:Actions: This logic should probably live in an internal package, maybe fromproto - attrs := make(map[string]identityschema.Attribute, len(l.IdentitySchema.IdentityAttributes)) - for _, attr := range l.IdentitySchema.IdentityAttributes { - switch { - case attr.Type.Is(tftypes.Bool): - attrs[attr.Name] = identityschema.BoolAttribute{ - RequiredForImport: attr.RequiredForImport, - OptionalForImport: attr.OptionalForImport, - // TODO:Actions: Do we need to set more than these? Probs not. - } - case attr.Type.Is(tftypes.Number): - attrs[attr.Name] = identityschema.NumberAttribute{ - RequiredForImport: attr.RequiredForImport, - OptionalForImport: attr.OptionalForImport, - // TODO:Actions: Do we need to set more than these? Probs not. - } - case attr.Type.Is(tftypes.String): - attrs[attr.Name] = identityschema.StringAttribute{ - RequiredForImport: attr.RequiredForImport, - OptionalForImport: attr.OptionalForImport, - // TODO:Actions: Do we need to set more than these? Probs not. - } - // TODO:Actions: All other types - // TODO:Actions: This should essentially be the inverse of toproto schema mapping logic - } - } - return identityschema.Schema{ - Attributes: attrs, - // TODO:Actions: Do we need to set more than these? Probs not. - } -} diff --git a/internal/fromproto5/resource_schema.go b/internal/fromproto5/resource_schema.go new file mode 100644 index 000000000..05b07bd9c --- /dev/null +++ b/internal/fromproto5/resource_schema.go @@ -0,0 +1,203 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto5 + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// ResourceSchema converts a *tfprotov5.Schema into a resource/schema Schema, used for +// converting raw linked resource schemas (from another provider server, such as SDKv2 or terraform-plugin-go) +// into Framework schemas. +func ResourceSchema(ctx context.Context, s *tfprotov5.Schema) (*resourceschema.Schema, error) { + if s == nil || s.Block == nil { + return nil, nil + } + + attrs, err := ResourceSchemaAttributes(ctx, s.Block.Attributes) + if err != nil { + return nil, err + } + + blocks, err := ResourceSchemaNestedBlocks(ctx, s.Block.BlockTypes) + if err != nil { + return nil, err + } + + return &resourceschema.Schema{ + // MAINTAINER NOTE: There isn't a need to copy all of the data from the protocol schema + // to the resource schema, just enough data to allow provider developers to read and set data. + Attributes: attrs, + Blocks: blocks, + }, nil +} + +func ResourceSchemaAttributes(ctx context.Context, protoAttrs []*tfprotov5.SchemaAttribute) (map[string]resourceschema.Attribute, error) { + attrs := make(map[string]resourceschema.Attribute, len(protoAttrs)) + for _, protoAttr := range protoAttrs { + // MAINTAINER NOTE: There isn't a need to copy all of the data from the protocol schema attribute + // to the resource schema attribute, just enough data to allow provider developers to read and set data. + switch { + case protoAttr.Type.Is(tftypes.Bool): + attrs[protoAttr.Name] = resourceschema.BoolAttribute{ + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.Number): + attrs[protoAttr.Name] = resourceschema.NumberAttribute{ + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.String): + attrs[protoAttr.Name] = resourceschema.StringAttribute{ + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.DynamicPseudoType): + attrs[protoAttr.Name] = resourceschema.DynamicAttribute{ + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.List{}): + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + l := protoAttr.Type.(tftypes.List) + + elementType, err := basetypes.TerraformTypeToFrameworkType(l.ElementType) + if err != nil { + return nil, err + } + + attrs[protoAttr.Name] = resourceschema.ListAttribute{ + ElementType: elementType, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.Map{}): + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + m := protoAttr.Type.(tftypes.Map) + + elementType, err := basetypes.TerraformTypeToFrameworkType(m.ElementType) + if err != nil { + return nil, err + } + + attrs[protoAttr.Name] = resourceschema.MapAttribute{ + ElementType: elementType, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.Set{}): + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + s := protoAttr.Type.(tftypes.Set) + + elementType, err := basetypes.TerraformTypeToFrameworkType(s.ElementType) + if err != nil { + return nil, err + } + + attrs[protoAttr.Name] = resourceschema.SetAttribute{ + ElementType: elementType, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.Object{}): + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + o := protoAttr.Type.(tftypes.Object) + + attrTypes := make(map[string]attr.Type, len(o.AttributeTypes)) + for name, tfType := range o.AttributeTypes { + t, err := basetypes.TerraformTypeToFrameworkType(tfType) + if err != nil { + return nil, err + } + attrTypes[name] = t + } + + attrs[protoAttr.Name] = resourceschema.ObjectAttribute{ + AttributeTypes: attrTypes, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + Sensitive: protoAttr.Sensitive, + } + default: + // MAINTAINER NOTE: Currently the only type not supported by Framework is a tuple, since there + // is no corresponding attribute to represent it. + // + // https://github.com/hashicorp/terraform-plugin-framework/issues/54 + return nil, fmt.Errorf("no supported attribute for tftypes.Type: %T", protoAttr.Type) + } + } + + return attrs, nil +} + +func ResourceSchemaNestedBlocks(ctx context.Context, protoBlocks []*tfprotov5.SchemaNestedBlock) (map[string]resourceschema.Block, error) { + nestedBlocks := make(map[string]resourceschema.Block, len(protoBlocks)) + for _, protoBlock := range protoBlocks { + if protoBlock.Block == nil { + continue + } + + attrs, err := ResourceSchemaAttributes(ctx, protoBlock.Block.Attributes) + if err != nil { + return nil, err + } + blocks, err := ResourceSchemaNestedBlocks(ctx, protoBlock.Block.BlockTypes) + if err != nil { + return nil, err + } + + switch protoBlock.Nesting { + case tfprotov5.SchemaNestedBlockNestingModeList: + nestedBlocks[protoBlock.TypeName] = resourceschema.ListNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: attrs, + Blocks: blocks, + }, + } + case tfprotov5.SchemaNestedBlockNestingModeSet: + nestedBlocks[protoBlock.TypeName] = resourceschema.SetNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: attrs, + Blocks: blocks, + }, + } + case tfprotov5.SchemaNestedBlockNestingModeSingle: + nestedBlocks[protoBlock.TypeName] = resourceschema.SingleNestedBlock{ + Attributes: attrs, + Blocks: blocks, + } + default: + return nil, fmt.Errorf("unrecognized nesting mode %v in nested block %q", protoBlock.Nesting, protoBlock.TypeName) + } + } + + return nestedBlocks, nil +} diff --git a/internal/fromproto5/resource_schema_test.go b/internal/fromproto5/resource_schema_test.go new file mode 100644 index 000000000..aafab86cc --- /dev/null +++ b/internal/fromproto5/resource_schema_test.go @@ -0,0 +1,233 @@ +// 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/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestResourceSchema(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input *tfprotov5.Schema + expected *resourceschema.Schema + expectedErr string + }{ + "nil": { + input: nil, + expected: nil, + }, + "no-block": { + input: &tfprotov5.Schema{}, + expected: nil, + }, + "no-attrs-no-nested-blocks": { + input: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{}, + }, + expected: &resourceschema.Schema{ + Attributes: make(map[string]resourceschema.Attribute, 0), + Blocks: make(map[string]resourceschema.Block, 0), + }, + }, + "primitives-attrs": { + input: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + { + Name: "number", + Type: tftypes.Number, + Optional: true, + Computed: true, + }, + { + Name: "string", + Type: tftypes.String, + Optional: true, + Computed: true, + Sensitive: true, + }, + { + Name: "dynamic", + Type: tftypes.DynamicPseudoType, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Required: true, + }, + "number": resourceschema.NumberAttribute{ + Optional: true, + Computed: true, + }, + "string": resourceschema.StringAttribute{ + Optional: true, + Computed: true, + Sensitive: true, + }, + "dynamic": resourceschema.DynamicAttribute{ + Optional: true, + WriteOnly: true, + }, + }, + Blocks: make(map[string]resourceschema.Block, 0), + }, + }, + "collection-attrs": { + input: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "list_of_bools", + Type: tftypes.List{ElementType: tftypes.Bool}, + Required: true, + }, + { + Name: "map_of_numbers", + Type: tftypes.Map{ElementType: tftypes.Number}, + Optional: true, + Computed: true, + }, + { + Name: "set_of_strings", + Type: tftypes.Set{ElementType: tftypes.String}, + Optional: true, + Computed: true, + Sensitive: true, + }, + { + Name: "list_of_objects", + Type: tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic": tftypes.DynamicPseudoType, + "string": tftypes.String, + }, + }, + }, + Required: true, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "list_of_bools": resourceschema.ListAttribute{ + ElementType: basetypes.BoolType{}, + Required: true, + }, + "map_of_numbers": resourceschema.MapAttribute{ + ElementType: basetypes.NumberType{}, + Optional: true, + Computed: true, + }, + "set_of_strings": resourceschema.SetAttribute{ + ElementType: basetypes.StringType{}, + Optional: true, + Computed: true, + Sensitive: true, + }, + "list_of_objects": resourceschema.ListAttribute{ + ElementType: basetypes.ObjectType{ + AttrTypes: map[string]attr.Type{ + "dynamic": basetypes.DynamicType{}, + "string": basetypes.StringType{}, + }, + }, + Required: true, + }, + }, + Blocks: make(map[string]resourceschema.Block, 0), + }, + }, + "object-attr": { + input: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "object", + Type: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "bool": tftypes.Bool, + "dynamic": tftypes.DynamicPseudoType, + "string": tftypes.String, + }, + }, + Optional: true, + Computed: true, + Sensitive: true, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "object": resourceschema.ObjectAttribute{ + AttributeTypes: map[string]attr.Type{ + "bool": basetypes.BoolType{}, + "dynamic": basetypes.DynamicType{}, + "string": basetypes.StringType{}, + }, + Optional: true, + Computed: true, + Sensitive: true, + }, + }, + Blocks: make(map[string]resourceschema.Block, 0), + }, + }, + // TODO:Actions: tuple error test + // TODO:Actions: list nested block w/ attrs + blocks + // TODO:Actions: set nested block w/ attrs + blocks + // TODO:Actions: single nested block w/ attrs + blocks + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := fromproto5.ResourceSchema(context.Background(), tc.input) + if err != nil { + if tc.expectedErr == "" { + t.Errorf("Unexpected error: %s", err) + return + } + if err.Error() != tc.expectedErr { + t.Errorf("Expected error to be %q, got %q", tc.expectedErr, err.Error()) + return + } + // got expected error + return + } + if tc.expectedErr != "" { + t.Errorf("Expected error to be %q, got nil", tc.expectedErr) + return + } + if diff := cmp.Diff(got, tc.expected); diff != "" { + t.Errorf("Unexpected diff (+wanted, -got): %s", diff) + return + } + }) + } +} diff --git a/internal/proto5server/server_planaction.go b/internal/proto5server/server_planaction.go index 9a807106f..a65a4b95d 100644 --- a/internal/proto5server/server_planaction.go +++ b/internal/proto5server/server_planaction.go @@ -42,11 +42,31 @@ func (s *Server) PlanAction(ctx context.Context, proto5Req *tfprotov5.PlanAction lrIdentitySchemas := make([]fwschema.Schema, 0) for _, lrType := range actionSchema.LinkedResourceTypes() { switch lrType := lrType.(type) { - case schema.RawLinkedResource: + case schema.RawV5LinkedResource: // schema.RawLinkedResource are not on the same provider server, so we retrieve the schemas from the // definition directly. - lrSchemas = append(lrSchemas, lrType.GetSchema()) - lrIdentitySchemas = append(lrIdentitySchemas, lrType.GetIdentitySchema()) + lrSchema, err := fromproto5.ResourceSchema(ctx, lrType.Schema()) + if err != nil { + // TODO:Actions: Add diagnostic and return + } + lrSchemas = append(lrSchemas, lrSchema) + + // TODO:Actions: Implement the mapping logic for identity schemas + // + // lrIdentitySchema, err := fromproto5.ResourceIdentitySchema(ctx, lrType.IdentitySchema()) + // if err != nil { + // // TODO:Actions: Add diagnostic and return + // } + // lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) + case schema.RawV6LinkedResource: + // TODO:Actions: Would it be invalid to use a v6 linked resource in a v5 action? My initial thought is that + // this would never happen (since the provider must all be the same protocol version at the end of the day to Terraform, + // and providers can't build actions for other providers), but I can't think of a reason why we couldn't do this? + // + // The data is all the same under the hood, but perhaps there are some validations that might break down when attempting to prevent + // setting data in nested computed attributes? :shrug: + // + // We can very easily validate this in the proto5server/proto6server in our type switch, just need to determine if that restriction is reasonable. default: // Any other linked resource type should be on the same provider server as the action, so we can just retrieve it lrSchema, diags := s.FrameworkServer.ResourceSchema(ctx, lrType.GetTypeName()) diff --git a/types/basetypes/dynamic_type.go b/types/basetypes/dynamic_type.go index 87138984a..8e949be9a 100644 --- a/types/basetypes/dynamic_type.go +++ b/types/basetypes/dynamic_type.go @@ -91,7 +91,7 @@ func (t DynamicType) ValueFromTerraform(ctx context.Context, in tftypes.Value) ( return nil, errors.New("ambiguous known value for `tftypes.DynamicPseudoType` detected") } - attrType, err := tftypeToFrameworkType(in.Type()) + attrType, err := TerraformTypeToFrameworkType(in.Type()) if err != nil { return nil, err } @@ -108,91 +108,3 @@ func (t DynamicType) ValueFromTerraform(ctx context.Context, in tftypes.Value) ( func (t DynamicType) ValueType(_ context.Context) attr.Value { return DynamicValue{} } - -// tftypeToFrameworkType is a helper function that returns the framework type equivalent for a given Terraform type. -// -// Custom dynamic type implementations shouldn't need to override this method, but if needed, they can implement similar logic -// in their `ValueFromTerraform` implementation. -func tftypeToFrameworkType(in tftypes.Type) (attr.Type, error) { - // Primitive types - if in.Is(tftypes.Bool) { - return BoolType{}, nil - } - if in.Is(tftypes.Number) { - return NumberType{}, nil - } - if in.Is(tftypes.String) { - return StringType{}, nil - } - - if in.Is(tftypes.DynamicPseudoType) { - // Null and Unknown values that do not have a type determined will have a type of DynamicPseudoType - return DynamicType{}, nil - } - - // Collection types - if in.Is(tftypes.List{}) { - //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function - l := in.(tftypes.List) - - elemType, err := tftypeToFrameworkType(l.ElementType) - if err != nil { - return nil, err - } - return ListType{ElemType: elemType}, nil - } - if in.Is(tftypes.Map{}) { - //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function - m := in.(tftypes.Map) - - elemType, err := tftypeToFrameworkType(m.ElementType) - if err != nil { - return nil, err - } - - return MapType{ElemType: elemType}, nil - } - if in.Is(tftypes.Set{}) { - //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function - s := in.(tftypes.Set) - - elemType, err := tftypeToFrameworkType(s.ElementType) - if err != nil { - return nil, err - } - - return SetType{ElemType: elemType}, nil - } - - // Structural types - if in.Is(tftypes.Object{}) { - //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function - o := in.(tftypes.Object) - - attrTypes := make(map[string]attr.Type, len(o.AttributeTypes)) - for name, tfType := range o.AttributeTypes { - t, err := tftypeToFrameworkType(tfType) - if err != nil { - return nil, err - } - attrTypes[name] = t - } - return ObjectType{AttrTypes: attrTypes}, nil - } - if in.Is(tftypes.Tuple{}) { - //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function - tup := in.(tftypes.Tuple) - - elemTypes := make([]attr.Type, len(tup.ElementTypes)) - for i, tfType := range tup.ElementTypes { - t, err := tftypeToFrameworkType(tfType) - if err != nil { - return nil, err - } - elemTypes[i] = t - } - return TupleType{ElemTypes: elemTypes}, nil - } - - return nil, fmt.Errorf("unsupported tftypes.Type detected: %T", in) -} diff --git a/types/basetypes/terraform_type_to_framework_type.go b/types/basetypes/terraform_type_to_framework_type.go new file mode 100644 index 000000000..b1d2e77db --- /dev/null +++ b/types/basetypes/terraform_type_to_framework_type.go @@ -0,0 +1,95 @@ +package basetypes + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// TODO:Actions: Add dedicated tests for this function + +// TerraformTypeToFrameworkType is a helper function that returns the framework type equivalent for a given Terraform type. +func TerraformTypeToFrameworkType(in tftypes.Type) (attr.Type, error) { + // Primitive types + if in.Is(tftypes.Bool) { + return BoolType{}, nil + } + if in.Is(tftypes.Number) { + return NumberType{}, nil + } + if in.Is(tftypes.String) { + return StringType{}, nil + } + + if in.Is(tftypes.DynamicPseudoType) { + // Null and Unknown values that do not have a type determined will have a type of DynamicPseudoType + return DynamicType{}, nil + } + + // Collection types + if in.Is(tftypes.List{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + l := in.(tftypes.List) + + elemType, err := TerraformTypeToFrameworkType(l.ElementType) + if err != nil { + return nil, err + } + return ListType{ElemType: elemType}, nil + } + if in.Is(tftypes.Map{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + m := in.(tftypes.Map) + + elemType, err := TerraformTypeToFrameworkType(m.ElementType) + if err != nil { + return nil, err + } + + return MapType{ElemType: elemType}, nil + } + if in.Is(tftypes.Set{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + s := in.(tftypes.Set) + + elemType, err := TerraformTypeToFrameworkType(s.ElementType) + if err != nil { + return nil, err + } + + return SetType{ElemType: elemType}, nil + } + + // Structural types + if in.Is(tftypes.Object{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + o := in.(tftypes.Object) + + attrTypes := make(map[string]attr.Type, len(o.AttributeTypes)) + for name, tfType := range o.AttributeTypes { + t, err := TerraformTypeToFrameworkType(tfType) + if err != nil { + return nil, err + } + attrTypes[name] = t + } + return ObjectType{AttrTypes: attrTypes}, nil + } + if in.Is(tftypes.Tuple{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + tup := in.(tftypes.Tuple) + + elemTypes := make([]attr.Type, len(tup.ElementTypes)) + for i, tfType := range tup.ElementTypes { + t, err := TerraformTypeToFrameworkType(tfType) + if err != nil { + return nil, err + } + elemTypes[i] = t + } + return TupleType{ElemTypes: elemTypes}, nil + } + + return nil, fmt.Errorf("unsupported tftypes.Type detected: %T", in) +} From 5a247b5db5bf23362c03255f04932353895cf53c Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 30 Jul 2025 17:28:36 -0400 Subject: [PATCH 06/18] tests tests tests --- internal/fromproto5/resource_schema.go | 13 +- internal/fromproto5/resource_schema_test.go | 236 +++++++++++++++++- .../terraform_type_to_framework_type.go | 3 - .../terraform_type_to_framework_type_test.go | 113 +++++++++ 4 files changed, 351 insertions(+), 14 deletions(-) create mode 100644 types/basetypes/terraform_type_to_framework_type_test.go diff --git a/internal/fromproto5/resource_schema.go b/internal/fromproto5/resource_schema.go index 05b07bd9c..7509609ff 100644 --- a/internal/fromproto5/resource_schema.go +++ b/internal/fromproto5/resource_schema.go @@ -33,7 +33,7 @@ func ResourceSchema(ctx context.Context, s *tfprotov5.Schema) (*resourceschema.S } return &resourceschema.Schema{ - // MAINTAINER NOTE: There isn't a need to copy all of the data from the protocol schema + // MAINTAINER NOTE: At the moment, there isn't a need to copy all of the data from the protocol schema // to the resource schema, just enough data to allow provider developers to read and set data. Attributes: attrs, Blocks: blocks, @@ -43,8 +43,8 @@ func ResourceSchema(ctx context.Context, s *tfprotov5.Schema) (*resourceschema.S func ResourceSchemaAttributes(ctx context.Context, protoAttrs []*tfprotov5.SchemaAttribute) (map[string]resourceschema.Attribute, error) { attrs := make(map[string]resourceschema.Attribute, len(protoAttrs)) for _, protoAttr := range protoAttrs { - // MAINTAINER NOTE: There isn't a need to copy all of the data from the protocol schema attribute - // to the resource schema attribute, just enough data to allow provider developers to read and set data. + // MAINTAINER NOTE: At the moment, there isn't a need to copy all of the data from the protocol schema + // to the resource schema, just enough data to allow provider developers to read and set data. switch { case protoAttr.Type.Is(tftypes.Bool): attrs[protoAttr.Name] = resourceschema.BoolAttribute{ @@ -92,6 +92,7 @@ func ResourceSchemaAttributes(ctx context.Context, protoAttrs []*tfprotov5.Schem Required: protoAttr.Required, Optional: protoAttr.Optional, Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, Sensitive: protoAttr.Sensitive, } case protoAttr.Type.Is(tftypes.Map{}): @@ -108,6 +109,7 @@ func ResourceSchemaAttributes(ctx context.Context, protoAttrs []*tfprotov5.Schem Required: protoAttr.Required, Optional: protoAttr.Optional, Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, Sensitive: protoAttr.Sensitive, } case protoAttr.Type.Is(tftypes.Set{}): @@ -144,6 +146,7 @@ func ResourceSchemaAttributes(ctx context.Context, protoAttrs []*tfprotov5.Schem Required: protoAttr.Required, Optional: protoAttr.Optional, Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, Sensitive: protoAttr.Sensitive, } default: @@ -195,7 +198,9 @@ func ResourceSchemaNestedBlocks(ctx context.Context, protoBlocks []*tfprotov5.Sc Blocks: blocks, } default: - return nil, fmt.Errorf("unrecognized nesting mode %v in nested block %q", protoBlock.Nesting, protoBlock.TypeName) + // MAINTAINER NOTE: Currently the only block type not supported by Framework is a map nested block, since there + // is no corresponding framework block implementation to represent it. + return nil, fmt.Errorf("no supported block for nesting mode %v in nested block %q", protoBlock.Nesting, protoBlock.TypeName) } } diff --git a/internal/fromproto5/resource_schema_test.go b/internal/fromproto5/resource_schema_test.go index aafab86cc..6c2a68b1c 100644 --- a/internal/fromproto5/resource_schema_test.go +++ b/internal/fromproto5/resource_schema_test.go @@ -99,9 +99,10 @@ func TestResourceSchema(t *testing.T) { Block: &tfprotov5.SchemaBlock{ Attributes: []*tfprotov5.SchemaAttribute{ { - Name: "list_of_bools", - Type: tftypes.List{ElementType: tftypes.Bool}, - Required: true, + Name: "list_of_bools", + Type: tftypes.List{ElementType: tftypes.Bool}, + Required: true, + WriteOnly: true, }, { Name: "map_of_numbers", @@ -136,6 +137,7 @@ func TestResourceSchema(t *testing.T) { "list_of_bools": resourceschema.ListAttribute{ ElementType: basetypes.BoolType{}, Required: true, + WriteOnly: true, }, "map_of_numbers": resourceschema.MapAttribute{ ElementType: basetypes.NumberType{}, @@ -197,10 +199,230 @@ func TestResourceSchema(t *testing.T) { Blocks: make(map[string]resourceschema.Block, 0), }, }, - // TODO:Actions: tuple error test - // TODO:Actions: list nested block w/ attrs + blocks - // TODO:Actions: set nested block w/ attrs + blocks - // TODO:Actions: single nested block w/ attrs + blocks + "tuple-error": { + input: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "tuple", + Type: tftypes.Tuple{ + ElementTypes: []tftypes.Type{ + tftypes.Bool, + tftypes.Number, + tftypes.String, + }, + }, + Required: true, + }, + }, + }, + }, + expectedErr: "no supported attribute for tftypes.Type: tftypes.Tuple", + }, + "list-block": { + input: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "list_block", + Nesting: tfprotov5.SchemaNestedBlockNestingModeList, + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "list_of_strings", + Type: tftypes.List{ElementType: tftypes.String}, + Computed: true, + }, + }, + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "nested_list_block", + Nesting: tfprotov5.SchemaNestedBlockNestingModeList, + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Blocks: map[string]resourceschema.Block{ + "list_block": resourceschema.ListNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: map[string]resourceschema.Attribute{ + "list_of_strings": resourceschema.ListAttribute{ + ElementType: basetypes.StringType{}, + Computed: true, + }, + }, + Blocks: map[string]resourceschema.Block{ + "nested_list_block": resourceschema.ListNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + Attributes: make(map[string]resourceschema.Attribute, 0), + }, + }, + "set-block": { + input: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "set_block", + Nesting: tfprotov5.SchemaNestedBlockNestingModeSet, + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "set_of_strings", + Type: tftypes.Set{ElementType: tftypes.String}, + Computed: true, + }, + }, + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "nested_set_block", + Nesting: tfprotov5.SchemaNestedBlockNestingModeSet, + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Blocks: map[string]resourceschema.Block{ + "set_block": resourceschema.SetNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: map[string]resourceschema.Attribute{ + "set_of_strings": resourceschema.SetAttribute{ + ElementType: basetypes.StringType{}, + Computed: true, + }, + }, + Blocks: map[string]resourceschema.Block{ + "nested_set_block": resourceschema.SetNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + Attributes: make(map[string]resourceschema.Attribute, 0), + }, + }, + "single-block": { + input: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "single_block", + Nesting: tfprotov5.SchemaNestedBlockNestingModeSingle, + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "map_of_strings", + Type: tftypes.Map{ElementType: tftypes.String}, + Computed: true, + }, + }, + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "nested_single_block", + Nesting: tfprotov5.SchemaNestedBlockNestingModeSingle, + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Blocks: map[string]resourceschema.Block{ + "single_block": resourceschema.SingleNestedBlock{ + Attributes: map[string]resourceschema.Attribute{ + "map_of_strings": resourceschema.MapAttribute{ + ElementType: basetypes.StringType{}, + Computed: true, + }, + }, + Blocks: map[string]resourceschema.Block{ + "nested_single_block": resourceschema.SingleNestedBlock{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Required: true, + }, + }, + }, + }, + }, + }, + Attributes: make(map[string]resourceschema.Attribute, 0), + }, + }, + "map-block": { + input: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "map_block", + Nesting: tfprotov5.SchemaNestedBlockNestingModeMap, + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + }, + }, + }, + }, + }, + }, + expectedErr: `no supported block for nesting mode MAP in nested block "map_block"`, + }, } for name, tc := range testCases { diff --git a/types/basetypes/terraform_type_to_framework_type.go b/types/basetypes/terraform_type_to_framework_type.go index b1d2e77db..e0db55c56 100644 --- a/types/basetypes/terraform_type_to_framework_type.go +++ b/types/basetypes/terraform_type_to_framework_type.go @@ -7,8 +7,6 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" ) -// TODO:Actions: Add dedicated tests for this function - // TerraformTypeToFrameworkType is a helper function that returns the framework type equivalent for a given Terraform type. func TerraformTypeToFrameworkType(in tftypes.Type) (attr.Type, error) { // Primitive types @@ -23,7 +21,6 @@ func TerraformTypeToFrameworkType(in tftypes.Type) (attr.Type, error) { } if in.Is(tftypes.DynamicPseudoType) { - // Null and Unknown values that do not have a type determined will have a type of DynamicPseudoType return DynamicType{}, nil } diff --git a/types/basetypes/terraform_type_to_framework_type_test.go b/types/basetypes/terraform_type_to_framework_type_test.go new file mode 100644 index 000000000..955442e67 --- /dev/null +++ b/types/basetypes/terraform_type_to_framework_type_test.go @@ -0,0 +1,113 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package basetypes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestTerraformTypeToFrameworkType(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + input tftypes.Type + expected attr.Type + }{ + "bool": { + input: tftypes.Bool, + expected: BoolType{}, + }, + "number": { + input: tftypes.Number, + expected: NumberType{}, + }, + "string": { + input: tftypes.String, + expected: StringType{}, + }, + "dynamic": { + input: tftypes.DynamicPseudoType, + expected: DynamicType{}, + }, + "list": { + input: tftypes.List{ElementType: tftypes.Bool}, + expected: ListType{ElemType: BoolType{}}, + }, + "set": { + input: tftypes.Set{ElementType: tftypes.Number}, + expected: SetType{ElemType: NumberType{}}, + }, + "map": { + input: tftypes.Map{ElementType: tftypes.String}, + expected: MapType{ElemType: StringType{}}, + }, + "object": { + input: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "bool": tftypes.Bool, + "list": tftypes.List{ElementType: tftypes.Number}, + "nested_obj": tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "map": tftypes.Map{ElementType: tftypes.DynamicPseudoType}, + "string": tftypes.String, + }, + }, + }, + }, + expected: ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": BoolType{}, + "list": ListType{ElemType: NumberType{}}, + "nested_obj": ObjectType{ + AttrTypes: map[string]attr.Type{ + "map": MapType{ElemType: DynamicType{}}, + "string": StringType{}, + }, + }, + }, + }, + }, + "tuple": { + input: tftypes.Tuple{ + ElementTypes: []tftypes.Type{ + tftypes.Bool, + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "list": tftypes.List{ElementType: tftypes.DynamicPseudoType}, + "number": tftypes.Number, + }, + }, + tftypes.Map{ElementType: tftypes.String}, + }, + }, + expected: TupleType{ + ElemTypes: []attr.Type{ + BoolType{}, + ObjectType{ + AttrTypes: map[string]attr.Type{ + "list": ListType{ElemType: DynamicType{}}, + "number": NumberType{}, + }, + }, + MapType{ElemType: StringType{}}, + }, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, _ := TerraformTypeToFrameworkType(test.input) + if diff := cmp.Diff(got, test.expected); diff != "" { + t.Errorf("Unexpected diff (-expected, +got): %s", diff) + } + }) + } +} From 7951721bd6b603631b17583a0aa785725ed04313 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 31 Jul 2025 17:42:16 -0400 Subject: [PATCH 07/18] identity schema mapping --- internal/fromproto5/identity_schema.go | 79 +++++++++++ internal/fromproto5/identity_schema_test.go | 142 ++++++++++++++++++++ internal/fromproto5/resource_schema.go | 2 +- internal/fromproto5/resource_schema_test.go | 2 +- 4 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 internal/fromproto5/identity_schema.go create mode 100644 internal/fromproto5/identity_schema_test.go diff --git a/internal/fromproto5/identity_schema.go b/internal/fromproto5/identity_schema.go new file mode 100644 index 000000000..c6d70a91c --- /dev/null +++ b/internal/fromproto5/identity_schema.go @@ -0,0 +1,79 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto5 + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// IdentitySchema converts a *tfprotov5.ResourceIdentitySchema into a resource/identityschema Schema, used for +// converting raw linked resource identity schemas (from another provider server, such as SDKv2 or terraform-plugin-go) +// into Framework identity schemas. +func IdentitySchema(ctx context.Context, s *tfprotov5.ResourceIdentitySchema) (*identityschema.Schema, error) { + if s == nil { + return nil, nil + } + + attrs, err := IdentitySchemaAttributes(ctx, s.IdentityAttributes) + if err != nil { + return nil, err + } + + return &identityschema.Schema{ + // MAINTAINER NOTE: At the moment, there isn't a need to copy all of the data from the protocol identity schema + // to the resource identity schema, just enough data to allow provider developers to read and set data. + Attributes: attrs, + }, nil +} + +func IdentitySchemaAttributes(ctx context.Context, protoAttrs []*tfprotov5.ResourceIdentitySchemaAttribute) (map[string]identityschema.Attribute, error) { + attrs := make(map[string]identityschema.Attribute, len(protoAttrs)) + for _, protoAttr := range protoAttrs { + // MAINTAINER NOTE: At the moment, there isn't a need to copy all of the data from the protocol identity schema + // to the resource identity schema, just enough data to allow provider developers to read and set data. + switch { + case protoAttr.Type.Is(tftypes.Bool): + attrs[protoAttr.Name] = identityschema.BoolAttribute{ + RequiredForImport: protoAttr.RequiredForImport, + OptionalForImport: protoAttr.OptionalForImport, + } + case protoAttr.Type.Is(tftypes.Number): + attrs[protoAttr.Name] = identityschema.NumberAttribute{ + RequiredForImport: protoAttr.RequiredForImport, + OptionalForImport: protoAttr.OptionalForImport, + } + case protoAttr.Type.Is(tftypes.String): + attrs[protoAttr.Name] = identityschema.StringAttribute{ + RequiredForImport: protoAttr.RequiredForImport, + OptionalForImport: protoAttr.OptionalForImport, + } + case protoAttr.Type.Is(tftypes.List{}): + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + l := protoAttr.Type.(tftypes.List) + + elementType, err := basetypes.TerraformTypeToFrameworkType(l.ElementType) + if err != nil { + return nil, err + } + + attrs[protoAttr.Name] = identityschema.ListAttribute{ + ElementType: elementType, + RequiredForImport: protoAttr.RequiredForImport, + OptionalForImport: protoAttr.OptionalForImport, + } + default: + // MAINTAINER NOTE: Not all terraform types are valid identity attribute types. Framework fully supports + // all of the possible identity attribute types, so any errors here would be invalid protocol identities. + return nil, fmt.Errorf("no supported identity attribute for %q, type: %T", protoAttr.Name, protoAttr.Type) + } + } + + return attrs, nil +} diff --git a/internal/fromproto5/identity_schema_test.go b/internal/fromproto5/identity_schema_test.go new file mode 100644 index 000000000..a9c4bcbf0 --- /dev/null +++ b/internal/fromproto5/identity_schema_test.go @@ -0,0 +1,142 @@ +// 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/internal/fromproto5" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestIdentitySchema(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input *tfprotov5.ResourceIdentitySchema + expected *identityschema.Schema + expectedErr string + }{ + "nil": { + input: nil, + expected: nil, + }, + "no-attrs": { + input: &tfprotov5.ResourceIdentitySchema{}, + expected: &identityschema.Schema{ + Attributes: make(map[string]identityschema.Attribute, 0), + }, + }, + "primitives-attrs": { + input: &tfprotov5.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + RequiredForImport: true, + }, + { + Name: "number", + Type: tftypes.Number, + OptionalForImport: true, + }, + { + Name: "string", + Type: tftypes.String, + OptionalForImport: true, + }, + }, + }, + expected: &identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "bool": identityschema.BoolAttribute{ + RequiredForImport: true, + }, + "number": identityschema.NumberAttribute{ + OptionalForImport: true, + }, + "string": identityschema.StringAttribute{ + OptionalForImport: true, + }, + }, + }, + }, + "list-attr": { + input: &tfprotov5.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + { + Name: "list_of_bools", + Type: tftypes.List{ElementType: tftypes.Bool}, + RequiredForImport: true, + }, + }, + }, + expected: &identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "list_of_bools": identityschema.ListAttribute{ + ElementType: basetypes.BoolType{}, + RequiredForImport: true, + }, + }, + }, + }, + "map-error": { + input: &tfprotov5.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + { + Name: "map_of_strings", + Type: tftypes.Map{ElementType: tftypes.String}, + OptionalForImport: true, + }, + }, + }, + expectedErr: `no supported identity attribute for "map_of_strings", type: tftypes.Map`, + }, + "set-error": { + input: &tfprotov5.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + { + Name: "set_of_strings", + Type: tftypes.Set{ElementType: tftypes.String}, + OptionalForImport: true, + }, + }, + }, + expectedErr: `no supported identity attribute for "set_of_strings", type: tftypes.Set`, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := fromproto5.IdentitySchema(context.Background(), tc.input) + if err != nil { + if tc.expectedErr == "" { + t.Errorf("Unexpected error: %s", err) + return + } + if err.Error() != tc.expectedErr { + t.Errorf("Expected error to be %q, got %q", tc.expectedErr, err.Error()) + return + } + // got expected error + return + } + if tc.expectedErr != "" { + t.Errorf("Expected error to be %q, got nil", tc.expectedErr) + return + } + if diff := cmp.Diff(got, tc.expected); diff != "" { + t.Errorf("Unexpected diff (+wanted, -got): %s", diff) + return + } + }) + } +} diff --git a/internal/fromproto5/resource_schema.go b/internal/fromproto5/resource_schema.go index 7509609ff..96c30c900 100644 --- a/internal/fromproto5/resource_schema.go +++ b/internal/fromproto5/resource_schema.go @@ -154,7 +154,7 @@ func ResourceSchemaAttributes(ctx context.Context, protoAttrs []*tfprotov5.Schem // is no corresponding attribute to represent it. // // https://github.com/hashicorp/terraform-plugin-framework/issues/54 - return nil, fmt.Errorf("no supported attribute for tftypes.Type: %T", protoAttr.Type) + return nil, fmt.Errorf("no supported attribute for %q, type: %T", protoAttr.Name, protoAttr.Type) } } diff --git a/internal/fromproto5/resource_schema_test.go b/internal/fromproto5/resource_schema_test.go index 6c2a68b1c..685f87e1c 100644 --- a/internal/fromproto5/resource_schema_test.go +++ b/internal/fromproto5/resource_schema_test.go @@ -217,7 +217,7 @@ func TestResourceSchema(t *testing.T) { }, }, }, - expectedErr: "no supported attribute for tftypes.Type: tftypes.Tuple", + expectedErr: `no supported attribute for "tuple", type: tftypes.Tuple`, }, "list-block": { input: &tfprotov5.Schema{ From 983fb280925a4562c9864e52da88d229050876ae Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 1 Aug 2025 11:07:29 -0400 Subject: [PATCH 08/18] proto5server plan action tests --- action/schema/linked_resource.go | 28 + internal/proto5server/server_planaction.go | 72 +- .../proto5server/server_planaction_test.go | 1096 ++++++++++++++++- 3 files changed, 1148 insertions(+), 48 deletions(-) diff --git a/action/schema/linked_resource.go b/action/schema/linked_resource.go index fcc3722ec..c805e8376 100644 --- a/action/schema/linked_resource.go +++ b/action/schema/linked_resource.go @@ -57,6 +57,20 @@ func (l RawV5LinkedResource) GetDescription() string { return l.Description } +func (l RawV5LinkedResource) GetSchema() *tfprotov5.Schema { + if l.Schema == nil { + return nil + } + return l.Schema() +} + +func (l RawV5LinkedResource) GetIdentitySchema() *tfprotov5.ResourceIdentitySchema { + if l.IdentitySchema == nil { + return nil + } + return l.IdentitySchema() +} + // TODO:Actions: docs type RawV6LinkedResource struct { TypeName string @@ -74,3 +88,17 @@ func (l RawV6LinkedResource) GetTypeName() string { func (l RawV6LinkedResource) GetDescription() string { return l.Description } + +func (l RawV6LinkedResource) GetSchema() *tfprotov6.Schema { + if l.Schema == nil { + return nil + } + return l.Schema() +} + +func (l RawV6LinkedResource) GetIdentitySchema() *tfprotov6.ResourceIdentitySchema { + if l.IdentitySchema == nil { + return nil + } + return l.IdentitySchema() +} diff --git a/internal/proto5server/server_planaction.go b/internal/proto5server/server_planaction.go index a65a4b95d..a41156fe6 100644 --- a/internal/proto5server/server_planaction.go +++ b/internal/proto5server/server_planaction.go @@ -5,6 +5,7 @@ package proto5server import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-framework/action/schema" "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" @@ -43,50 +44,67 @@ func (s *Server) PlanAction(ctx context.Context, proto5Req *tfprotov5.PlanAction for _, lrType := range actionSchema.LinkedResourceTypes() { switch lrType := lrType.(type) { case schema.RawV5LinkedResource: - // schema.RawLinkedResource are not on the same provider server, so we retrieve the schemas from the - // definition directly. - lrSchema, err := fromproto5.ResourceSchema(ctx, lrType.Schema()) + // Raw linked resources are not stored on this provider server, so we retrieve the schemas from the + // action definition directly and convert them to framework schemas. + lrSchema, err := fromproto5.ResourceSchema(ctx, lrType.GetSchema()) if err != nil { - // TODO:Actions: Add diagnostic and return + fwResp.Diagnostics.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %q linked resource schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), + ) + + return toproto5.PlanActionResponse(ctx, fwResp), nil //nolint:nilerr // error is assigned to fwResp.Diagnostics } lrSchemas = append(lrSchemas, lrSchema) - // TODO:Actions: Implement the mapping logic for identity schemas - // - // lrIdentitySchema, err := fromproto5.ResourceIdentitySchema(ctx, lrType.IdentitySchema()) - // if err != nil { - // // TODO:Actions: Add diagnostic and return - // } - // lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) + lrIdentitySchema, err := fromproto5.IdentitySchema(ctx, lrType.GetIdentitySchema()) + if err != nil { + fwResp.Diagnostics.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %q linked resource identity schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), + ) + + return toproto5.PlanActionResponse(ctx, fwResp), nil //nolint:nilerr // error is assigned to fwResp.Diagnostics + } + lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) case schema.RawV6LinkedResource: - // TODO:Actions: Would it be invalid to use a v6 linked resource in a v5 action? My initial thought is that - // this would never happen (since the provider must all be the same protocol version at the end of the day to Terraform, - // and providers can't build actions for other providers), but I can't think of a reason why we couldn't do this? - // - // The data is all the same under the hood, but perhaps there are some validations that might break down when attempting to prevent - // setting data in nested computed attributes? :shrug: - // - // We can very easily validate this in the proto5server/proto6server in our type switch, just need to determine if that restriction is reasonable. + fwResp.Diagnostics.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "The %[1]q linked resource is a protocol v6 resource but the provider is being served using protocol v5.", lrType.GetTypeName()), + ) + + return toproto5.PlanActionResponse(ctx, fwResp), nil //nolint:nilerr // error is assigned to fwResp.Diagnostics default: - // Any other linked resource type should be on the same provider server as the action, so we can just retrieve it + // Any other linked resource type should be stored on the same provider server as the action, + // so we can just retrieve it via the type name. lrSchema, diags := s.FrameworkServer.ResourceSchema(ctx, lrType.GetTypeName()) + if diags.HasError() { + fwResp.Diagnostics.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource data from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "The %[1]q linked resource was not found on the provider server.", lrType.GetTypeName()), + ) - fwResp.Diagnostics.Append(diags...) - if fwResp.Diagnostics.HasError() { - // TODO:Actions: Better error message return toproto5.PlanActionResponse(ctx, fwResp), nil } - lrSchemas = append(lrSchemas, lrSchema) lrIdentitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, lrType.GetTypeName()) - fwResp.Diagnostics.Append(diags...) if fwResp.Diagnostics.HasError() { - // TODO:Actions: Better error message + // If the resource is found, the identity schema will only return a diagnostic if the provider implementation + // returns an error from (resource.Resource).IdentitySchema method. return toproto5.PlanActionResponse(ctx, fwResp), nil } - lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) } } diff --git a/internal/proto5server/server_planaction_test.go b/internal/proto5server/server_planaction_test.go index d3017e4a9..bd9b18627 100644 --- a/internal/proto5server/server_planaction_test.go +++ b/internal/proto5server/server_planaction_test.go @@ -5,15 +5,21 @@ package proto5server import ( "context" + "fmt" "testing" "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/action" - "github.com/hashicorp/terraform-plugin-framework/action/schema" + actionschema "github.com/hashicorp/terraform-plugin-framework/action/schema" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -26,20 +32,120 @@ func TestServerPlanAction(t *testing.T) { }, } - testConfigDynamicValue := testNewDynamicValue(t, testType, map[string]tftypes.Value{ + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_computed": resourceschema.StringAttribute{ + Computed: true, + }, + "test_required": resourceschema.StringAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceSchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_computed": tftypes.String, + "test_required": tftypes.String, + }, + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testLinkedResourceIdentitySchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testActionConfigDynamicValue := testNewDynamicValue(t, testType, map[string]tftypes.Value{ "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), }) testEmptyDynamicValue := testNewDynamicValue(t, tftypes.Object{}, nil) - testUnlinkedSchema := schema.UnlinkedSchema{ - Attributes: map[string]schema.Attribute{ - "test_required": schema.StringAttribute{ + testUnlinkedSchema := actionschema.UnlinkedSchema{ + Attributes: map[string]actionschema.Attribute{ + "test_required": actionschema.StringAttribute{ Required: true, }, }, } + testLifecycleSchema := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.LinkedResource{ + TypeName: "test_linked_resource", + }, + } + + testLifecycleSchemaRaw := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV5LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov5.Schema { + return &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + IdentitySchema: func() *tfprotov5.ResourceIdentitySchema { + return &tfprotov5.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + { + Name: "test_id", + Type: tftypes.String, + RequiredForImport: true, + }, + }, + } + }, + }, + } + + testLifecycleSchemaRawNoIdentity := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV5LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov5.Schema { + return &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + }, + } + testCases := map[string]struct { server *Server request *tfprotov5.PlanActionRequest @@ -55,7 +161,7 @@ func TestServerPlanAction(t *testing.T) { func() action.Action { return &testprovider.Action{ SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { - resp.Schema = schema.UnlinkedSchema{} + resp.Schema = actionschema.UnlinkedSchema{} }, MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { resp.TypeName = "test_action" @@ -71,7 +177,9 @@ func TestServerPlanAction(t *testing.T) { Config: testEmptyDynamicValue, ActionType: "test_action", }, - expectedResponse: &tfprotov5.PlanActionResponse{}, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{}, + }, }, "request-config": { server: &Server{ @@ -108,30 +216,91 @@ func TestServerPlanAction(t *testing.T) { }, }, request: &tfprotov5.PlanActionRequest{ - Config: testConfigDynamicValue, + Config: testActionConfigDynamicValue, ActionType: "test_action", }, - expectedResponse: &tfprotov5.PlanActionResponse{}, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{}, + }, }, - "response-diagnostics": { + "request-linkedresource-no-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.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + } + }, + } + }, ActionsMethod: func(_ context.Context) []func() action.Action { return []func() action.Action{ func() action.Action { return &testprovider.ActionWithModifyPlan{ Action: &testprovider.Action{ SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { - resp.Schema = testUnlinkedSchema + resp.Schema = testLifecycleSchema }, MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { resp.TypeName = "test_action" }, }, ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { - resp.Diagnostics.AddWarning("warning summary", "warning detail") - resp.Diagnostics.AddError("error summary", "error detail") + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } }, } }, @@ -141,20 +310,905 @@ func TestServerPlanAction(t *testing.T) { }, }, request: &tfprotov5.PlanActionRequest{ - Config: testConfigDynamicValue, + Config: testEmptyDynamicValue, ActionType: "test_action", + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, }, expectedResponse: &tfprotov5.PlanActionResponse{ - Diagnostics: []*tfprotov5.Diagnostic{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{ { - Severity: tfprotov5.DiagnosticSeverityWarning, - Summary: "warning summary", - Detail: "warning detail", + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + }, + "request-linkedresource-with-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) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + var linkedResourceIdentityData struct { + TestID types.String `tfsdk:"test_id"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Identity.Get(ctx, &linkedResourceIdentityData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceIdentityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"id-123\", got: %s", linkedResourceIdentityData.TestID), + ) + return + } + }, + } + }, + } + }, }, + }, + }, + request: &tfprotov5.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.ProposedLinkedResource{ { - Severity: tfprotov5.DiagnosticSeverityError, - Summary: "error summary", - Detail: "error detail", + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, 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, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{ + { + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, 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, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + }, + "request-raw-linkedresource-no-identity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchemaRawNoIdentity + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{ + { + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + }, + "request-raw-linkedresource-with-identity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchemaRaw + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + var linkedResourceIdentityData struct { + TestID types.String `tfsdk:"test_id"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Identity.Get(ctx, &linkedResourceIdentityData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceIdentityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"id-123\", got: %s", linkedResourceIdentityData.TestID), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, 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, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{ + { + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, 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, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + }, + "response-linkedresource-no-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.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + // Should be copied over from request + if len(resp.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected resp.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(resp.LinkedResources[0].Plan.SetAttribute(ctx, path.Root("test_computed"), "new-plan-value")...) + if resp.Diagnostics.HasError() { + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{ + { + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-plan-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + }, + "response-linkedresource-with-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) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + // Should be copied over from request + if len(resp.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected resp.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(resp.LinkedResources[0].Plan.SetAttribute(ctx, path.Root("test_computed"), "new-plan-value")...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.LinkedResources[0].Identity.SetAttribute(ctx, path.Root("test_id"), "new-id-123")...) + if resp.Diagnostics.HasError() { + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, 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, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{ + { + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-plan-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + }, + }, + }, + }, + }, + "response-diagnostics": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testUnlinkedSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + resp.Diagnostics.AddWarning("warning summary", "warning detail") + resp.Diagnostics.AddError("error summary", "error detail") + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanActionRequest{ + Config: testActionConfigDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{}, + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + }, + }, + "response-linkedresource-not-found": { + 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 = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_not_the_right_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{}, + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_linked_resource\" linked resource data from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "The \"test_linked_resource\" linked resource was not found on the provider server.", + }, + }, + }, + }, + "response-raw-linkedresource-invalid-resource-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV5LinkedResource{ + TypeName: "test_invalid_linked_resource", + Schema: func() *tfprotov5.Schema { + return &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // Tuple is not supported in framework + { + Name: "test_tuple", + Type: tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.Bool}}, + Required: true, + }, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{}, + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_invalid_linked_resource\" linked resource schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "no supported attribute for \"test_tuple\", type: tftypes.Tuple", + }, + }, + }, + }, + "response-raw-linkedresource-invalid-identity-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV5LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov5.Schema { + return &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + IdentitySchema: func() *tfprotov5.ResourceIdentitySchema { + return &tfprotov5.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + // Set is not a valid type for resource identity + { + Name: "test_id", + Type: tftypes.Set{ElementType: tftypes.Bool}, + RequiredForImport: true, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{}, + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_linked_resource\" linked resource identity schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "no supported identity attribute for \"test_id\", type: tftypes.Set", + }, + }, + }, + }, + "response-raw-linkedresource-v6-resource-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV6LinkedResource{ + TypeName: "test_v6_linked_resource", + Schema: func() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedResponse: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{}, + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_v6_linked_resource\" linked resource schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "The \"test_v6_linked_resource\" linked resource is a protocol v6 resource but the provider is being served using protocol v5.", }, }, }, From 6b94432dee01dc254dd04a41d19f4da459dc13a5 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 1 Aug 2025 15:52:05 -0400 Subject: [PATCH 09/18] fromproto tests --- internal/fromproto5/planaction.go | 31 ++- internal/fromproto5/planaction_test.go | 322 ++++++++++++++++++++++++- 2 files changed, 333 insertions(+), 20 deletions(-) diff --git a/internal/fromproto5/planaction.go b/internal/fromproto5/planaction.go index b1087234c..00038def6 100644 --- a/internal/fromproto5/planaction.go +++ b/internal/fromproto5/planaction.go @@ -50,15 +50,13 @@ func PlanActionRequest(ctx context.Context, proto5 *tfprotov5.PlanActionRequest, fw.Config = config - if len(proto5.LinkedResources) != len(linkedResourceSchemas) || len(proto5.LinkedResources) != len(linkedResourceIdentitySchemas) { - // TODO:Actions: Should we update this error message to include identity schemas? They should always be in sync.... + if len(proto5.LinkedResources) != len(linkedResourceSchemas) { diags.AddError( "Mismatched Linked Resources in PlanAction Request", "An unexpected error was encountered when handling the request. "+ - "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"+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ fmt.Sprintf( - "Received %d linked resources, but the provider was expecting %d linked resources.", + "Received %d linked resource(s), but the provider was expecting %d linked resource(s).", len(proto5.LinkedResources), len(linkedResourceSchemas), ), @@ -67,6 +65,23 @@ func PlanActionRequest(ctx context.Context, proto5 *tfprotov5.PlanActionRequest, return nil, diags } + // MAINTAINER NOTE: The number of identity schemas should always be in sync (if not supported, will have nil), + // so this error check is more for panic prevention. + if len(proto5.LinkedResources) != len(linkedResourceIdentitySchemas) { + diags.AddError( + "Mismatched Linked Resources in PlanAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + fmt.Sprintf( + "Received %d linked resource(s), but the provider was expecting %d linked resource(s).", + len(proto5.LinkedResources), + len(linkedResourceIdentitySchemas), + ), + ) + + return nil, diags + } + for i, linkedResource := range proto5.LinkedResources { schema := linkedResourceSchemas[i] identitySchema := linkedResourceIdentitySchemas[i] @@ -91,13 +106,11 @@ func PlanActionRequest(ctx context.Context, proto5 *tfprotov5.PlanActionRequest, // it's not valid for Terraform core to send an identity for a linked resource that doesn't support one. This would likely indicate // that there is a bug in the definition of the linked resources (not including an identity schema when it is supported), or a bug in // either Terraform core/Framework. - // - // TODO:Actions: Update diagnostic to mention provider implementation may be incorrect? diags.AddError( "Unable to Convert Linked Resource Identity", "An unexpected error was encountered when converting a linked resource identity from the protocol type. "+ - fmt.Sprintf("Identity data was sent in the protocol to an action's linked resource that doesn't support identity, index: %d.\n\n", i)+ - "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.", + fmt.Sprintf("Linked resource (at index %d) contained identity data, but the resource doesn't support identity.\n\n", i)+ + "This is always a problem with the provider and should be reported to the provider developer.", ) return nil, diags } diff --git a/internal/fromproto5/planaction_test.go b/internal/fromproto5/planaction_test.go index e2a49cbc0..a3881d0d8 100644 --- a/internal/fromproto5/planaction_test.go +++ b/internal/fromproto5/planaction_test.go @@ -12,17 +12,27 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-framework/action" - "github.com/hashicorp/terraform-plugin-framework/action/schema" + actionschema "github.com/hashicorp/terraform-plugin-framework/action/schema" "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/fwserver" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) func TestPlanActionRequest(t *testing.T) { t.Parallel() + testEmptyProto5Value := tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}, map[string]tftypes.Value{}) + + testEmptyProto5DynamicValue, err := tfprotov5.NewDynamicValue(tftypes.Object{}, testEmptyProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + testProto5Type := tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "test_attribute": tftypes.String, @@ -39,21 +49,83 @@ func TestPlanActionRequest(t *testing.T) { t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) } - testUnlinkedSchema := schema.UnlinkedSchema{ - Attributes: map[string]schema.Attribute{ - "test_attribute": schema.StringAttribute{ + testLinkedResourceProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute_one": tftypes.String, + "test_attribute_two": tftypes.Bool, + }, + } + + testLinkedResourceProto5Value := tftypes.NewValue(testLinkedResourceProto5Type, map[string]tftypes.Value{ + "test_attribute_one": tftypes.NewValue(tftypes.String, "test-value-1"), + "test_attribute_two": tftypes.NewValue(tftypes.Bool, true), + }) + + testLinkedResourceProto5DynamicValue, err := tfprotov5.NewDynamicValue(testLinkedResourceProto5Type, testLinkedResourceProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_attribute_one": resourceschema.StringAttribute{ + Required: true, + }, + "test_attribute_two": resourceschema.BoolAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceIdentityProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testLinkedResourceIdentityProto5Value := tftypes.NewValue(testLinkedResourceIdentityProto5Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testLinkedResourceIdentityProto5DynamicValue, err := tfprotov5.NewDynamicValue(testLinkedResourceIdentityProto5Type, testLinkedResourceIdentityProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testUnlinkedSchema := actionschema.UnlinkedSchema{ + Attributes: map[string]actionschema.Attribute{ + "test_attribute": actionschema.StringAttribute{ Required: true, }, }, } + testLifecycleSchemaLinked := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.LinkedResource{ + TypeName: "test_linked_resource", + }, + } + testCases := map[string]struct { - input *tfprotov5.PlanActionRequest - actionSchema fwschema.Schema - actionImpl action.Action - providerMetaSchema fwschema.Schema - expected *fwserver.PlanActionRequest - expectedDiagnostics diag.Diagnostics + input *tfprotov5.PlanActionRequest + actionSchema fwschema.Schema + actionImpl action.Action + linkedResourceSchemas []fwschema.Schema + linkedResourceIdentitySchemas []fwschema.Schema + providerMetaSchema fwschema.Schema + expected *fwserver.PlanActionRequest + expectedDiagnostics diag.Diagnostics }{ "nil": { input: nil, @@ -124,13 +196,241 @@ func TestPlanActionRequest(t *testing.T) { }, }, }, + "linkedresource": { + input: &tfprotov5.PlanActionRequest{ + Config: &testEmptyProto5DynamicValue, + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + }, + actionSchema: testLifecycleSchemaLinked, + expected: &fwserver.PlanActionRequest{ + ActionSchema: testLifecycleSchemaLinked, + Config: &tfsdk.Config{ + Raw: testEmptyProto5Value, + Schema: testLifecycleSchemaLinked, + }, + LinkedResources: []*fwserver.PlanLinkedResourceRequest{ + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto5Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + }, + "linkedresources": { + input: &tfprotov5.PlanActionRequest{ + Config: &testEmptyProto5DynamicValue, + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + nil, // Second resource doesn't have an identity + }, + actionSchema: testLifecycleSchemaLinked, + expected: &fwserver.PlanActionRequest{ + ActionSchema: testLifecycleSchemaLinked, + Config: &tfsdk.Config{ + Raw: testEmptyProto5Value, + Schema: testLifecycleSchemaLinked, + }, + LinkedResources: []*fwserver.PlanLinkedResourceRequest{ + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto5Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + }, + }, + }, + }, + "linkedresources-mismatched-number-of-schemas": { + input: &tfprotov5.PlanActionRequest{ + Config: &testEmptyProto5DynamicValue, + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + nil, // Second resource doesn't have an identity + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Mismatched Linked Resources in PlanAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + "Received 2 linked resource(s), but the provider was expecting 1 linked resource(s).", + ), + }, + }, + "linkedresources-mismatched-number-of-identity-schemas": { + input: &tfprotov5.PlanActionRequest{ + Config: &testEmptyProto5DynamicValue, + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Mismatched Linked Resources in PlanAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + "Received 2 linked resource(s), but the provider was expecting 1 linked resource(s).", + ), + }, + }, + "linkedresources-no-identity-schema": { + input: &tfprotov5.PlanActionRequest{ + Config: &testEmptyProto5DynamicValue, + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + PriorIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + nil, + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Linked Resource Identity", + "An unexpected error was encountered when converting a linked resource identity from the protocol type. "+ + "Linked resource (at index 0) contained identity data, but the resource doesn't support identity.\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.", + ), + }, + }, } for name, testCase := range testCases { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto5.PlanActionRequest(context.Background(), testCase.input, testCase.actionImpl, testCase.actionSchema, []fwschema.Schema{}, []fwschema.Schema{}) // TODO:Actions: Temporary + got, diags := fromproto5.PlanActionRequest( + context.Background(), + testCase.input, + testCase.actionImpl, + testCase.actionSchema, + testCase.linkedResourceSchemas, + testCase.linkedResourceIdentitySchemas, + ) if diff := cmp.Diff(got, testCase.expected); diff != "" { t.Errorf("unexpected difference: %s", diff) From 521519ee6c4609662863b4b24a2bddb422931605 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 1 Aug 2025 16:18:22 -0400 Subject: [PATCH 10/18] toproto tests --- internal/fwserver/server_planaction.go | 66 +++++----- internal/fwserver/server_planaction_test.go | 23 +++- internal/toproto5/action_schema_test.go | 75 ++++++++++++ internal/toproto5/planaction.go | 2 - internal/toproto5/planaction_test.go | 128 +++++++++++++++++++- 5 files changed, 250 insertions(+), 44 deletions(-) diff --git a/internal/fwserver/server_planaction.go b/internal/fwserver/server_planaction.go index adb3df229..e29fdcab4 100644 --- a/internal/fwserver/server_planaction.go +++ b/internal/fwserver/server_planaction.go @@ -56,6 +56,38 @@ func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *P return } + // By default, copy over planned state and identity for each linked resource + resp.LinkedResources = make([]*PlanLinkedResourceResponse, len(req.LinkedResources)) + for i, lr := range req.LinkedResources { + if lr.PlannedState == nil { + // TODO:Actions: I'm not 100% sure if this is valid enough to be a concern, PlanResourceChange populates this with a null + // value of the resource schema type, but it'd be nice to not have to carry linked resource schemas this far + // if we don't need them. + // + // My current thought is that this isn't needed (a similar check would need to be done on identity). Specifically because + // actions should always be following a linked resource PlanResourceChange call. So this value should always be populated and + // this would more be protecting future logic from panicking if a bug existing in Terraform core or Framework/SDKv2. + resp.Diagnostics.AddError( + "Invalid PlannedState for Linked Resource", + "An unexpected error was encountered when planning an action with linked resources. "+ + fmt.Sprintf("Linked resource planned state was nil when received in the protocol, index: %d.\n\n", i)+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.", + ) + return + } + + resp.LinkedResources[i] = &PlanLinkedResourceResponse{ + PlannedState: planToState(*lr.PlannedState), + } + + if lr.PriorIdentity != nil { + resp.LinkedResources[i].PlannedIdentity = &tfsdk.ResourceIdentity{ + Schema: lr.PriorIdentity.Schema, + Raw: lr.PriorIdentity.Raw.Copy(), + } + } + } + if s.deferred != nil { logging.FrameworkDebug(ctx, "Provider has deferred response configured, automatically returning deferred response.", map[string]interface{}{ @@ -95,41 +127,8 @@ func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *P } } - // By default, copy over planned state and identity for each linked resource - resp.LinkedResources = make([]*PlanLinkedResourceResponse, len(req.LinkedResources)) - for i, lr := range req.LinkedResources { - if lr.PlannedState == nil { - // TODO:Actions: I'm not 100% sure if this is valid enough to be a concern, PlanResourceChange populates this with a null - // value of the resource schema type, but it'd be nice to not have to carry linked resource schemas this far - // if we don't need them. - // - // My current thought is that this isn't needed (a similar check would need to be done on identity). Specifically because - // actions should always be following a linked resource PlanResourceChange call. So this value should always be populated and - // this would more be protecting future logic from panicking if a bug existing in Terraform core or Framework/SDKv2. - resp.Diagnostics.AddError( - "Invalid PlannedState for Linked Resource", - "An unexpected error was encountered when planning an action with linked resources. "+ - fmt.Sprintf("Linked resource planned state was nil when received in the protocol, index: %d.\n\n", i)+ - "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.", - ) - return - } - - resp.LinkedResources[i] = &PlanLinkedResourceResponse{ - PlannedState: planToState(*lr.PlannedState), - } - - if lr.PriorIdentity != nil { - resp.LinkedResources[i].PlannedIdentity = &tfsdk.ResourceIdentity{ - Schema: lr.PriorIdentity.Schema, - Raw: lr.PriorIdentity.Raw.Copy(), - } - } - } - // TODO:Actions: Should we add support for schema plan modifiers? Technically you could re-use any framework plan modifier // implementations from the "resource/schema/planmodifier" package - if actionWithModifyPlan, ok := req.Action.(action.ActionWithModifyPlan); ok { logging.FrameworkTrace(ctx, "Action implements ActionWithModifyPlan") @@ -173,6 +172,7 @@ func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *P resp.Diagnostics = modifyPlanResp.Diagnostics resp.Deferred = modifyPlanResp.Deferred + // TODO:Actions: improve the error message if len(resp.LinkedResources) != len(modifyPlanResp.LinkedResources) { resp.Diagnostics.AddError( "Invalid Linked Resource Plan", diff --git a/internal/fwserver/server_planaction_test.go b/internal/fwserver/server_planaction_test.go index d922eb844..70a5a9e7a 100644 --- a/internal/fwserver/server_planaction_test.go +++ b/internal/fwserver/server_planaction_test.go @@ -70,7 +70,9 @@ func TestServerPlanAction(t *testing.T) { ActionSchema: testUnlinkedSchema, Action: &testprovider.Action{}, }, - expectedResponse: &fwserver.PlanActionResponse{}, + expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + }, }, "request-client-capabilities-deferral-allowed": { server: &fwserver.Server{ @@ -95,7 +97,9 @@ func TestServerPlanAction(t *testing.T) { }, }, }, - expectedResponse: &fwserver.PlanActionResponse{}, + expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + }, }, "request-config": { server: &fwserver.Server{ @@ -118,7 +122,9 @@ func TestServerPlanAction(t *testing.T) { }, }, }, - expectedResponse: &fwserver.PlanActionResponse{}, + expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + }, }, "action-configure-data": { server: &fwserver.Server{ @@ -155,7 +161,9 @@ func TestServerPlanAction(t *testing.T) { }, }, }, - expectedResponse: &fwserver.PlanActionResponse{}, + expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + }, }, "response-deferral-automatic": { server: &fwserver.Server{ @@ -182,7 +190,8 @@ func TestServerPlanAction(t *testing.T) { ClientCapabilities: testDeferralAllowed, }, expectedResponse: &fwserver.PlanActionResponse{ - Deferred: &action.Deferred{Reason: action.DeferredReasonProviderConfigUnknown}, + LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + Deferred: &action.Deferred{Reason: action.DeferredReasonProviderConfigUnknown}, }, }, "response-deferral-manual": { @@ -210,7 +219,8 @@ func TestServerPlanAction(t *testing.T) { ClientCapabilities: testDeferralAllowed, }, expectedResponse: &fwserver.PlanActionResponse{ - Deferred: &action.Deferred{Reason: action.DeferredReasonAbsentPrereq}, + LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + Deferred: &action.Deferred{Reason: action.DeferredReasonAbsentPrereq}, }, }, "response-diagnostics": { @@ -228,6 +238,7 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, Diagnostics: diag.Diagnostics{ diag.NewWarningDiagnostic( "warning summary", diff --git a/internal/toproto5/action_schema_test.go b/internal/toproto5/action_schema_test.go index 39b7e7c7f..d607037e9 100644 --- a/internal/toproto5/action_schema_test.go +++ b/internal/toproto5/action_schema_test.go @@ -92,6 +92,81 @@ func TestActionSchema(t *testing.T) { }, }, }, + "lifecycle": { + input: actionschema.LifecycleSchema{ + ExecutionOrder: actionschema.ExecutionOrderAfter, + LinkedResource: actionschema.LinkedResource{ + TypeName: "test_linked_resource", + Description: "A linked resource for this action", + }, + Attributes: map[string]actionschema.Attribute{ + "bool": actionschema.BoolAttribute{ + Optional: true, + }, + "string": actionschema.StringAttribute{ + Required: true, + }, + }, + Blocks: map[string]actionschema.Block{ + "single_block": actionschema.SingleNestedBlock{ + Attributes: map[string]actionschema.Attribute{ + "bool": actionschema.BoolAttribute{ + Required: true, + }, + "string": actionschema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + }, + expected: &tfprotov5.ActionSchema{ + Type: tfprotov5.LifecycleActionSchemaType{ + Executes: tfprotov5.LifecycleExecutionOrderAfter, + LinkedResource: &tfprotov5.LinkedResourceSchema{ + TypeName: "test_linked_resource", + Description: "A linked resource for this action", + }, + }, + Schema: &tfprotov5.Schema{ + Version: 0, + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Optional: true, + }, + { + Name: "string", + Type: tftypes.String, + Required: true, + }, + }, + BlockTypes: []*tfprotov5.SchemaNestedBlock{ + { + TypeName: "single_block", + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + { + Name: "string", + Type: tftypes.String, + Optional: true, + }, + }, + }, + Nesting: tfprotov5.SchemaNestedBlockNestingModeSingle, + }, + }, + }, + }, + }, + }, } for name, tc := range tests { diff --git a/internal/toproto5/planaction.go b/internal/toproto5/planaction.go index 5c56bee9e..76f6492a8 100644 --- a/internal/toproto5/planaction.go +++ b/internal/toproto5/planaction.go @@ -22,8 +22,6 @@ func PlanActionResponse(ctx context.Context, fw *fwserver.PlanActionResponse) *t Deferred: ActionDeferred(fw.Deferred), } - // TODO:Actions: Should we validate somewhere that no new linked resources are added to the response? I.E. the length - // matches the linked resource length in the action schema? proto5.LinkedResources = make([]*tfprotov5.PlannedLinkedResource, len(fw.LinkedResources)) for i, linkedResource := range fw.LinkedResources { diff --git a/internal/toproto5/planaction_test.go b/internal/toproto5/planaction_test.go index 44aca43ae..705ebfbde 100644 --- a/internal/toproto5/planaction_test.go +++ b/internal/toproto5/planaction_test.go @@ -9,11 +9,15 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) func TestPlanActionResponse(t *testing.T) { @@ -27,6 +31,59 @@ func TestPlanActionResponse(t *testing.T) { Reason: tfprotov5.DeferredReasonAbsentPrereq, } + testLinkedResourceProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute_one": tftypes.String, + "test_attribute_two": tftypes.Bool, + }, + } + + testLinkedResourceProto5Value := tftypes.NewValue(testLinkedResourceProto5Type, map[string]tftypes.Value{ + "test_attribute_one": tftypes.NewValue(tftypes.String, "test-value-1"), + "test_attribute_two": tftypes.NewValue(tftypes.Bool, true), + }) + + testLinkedResourceProto5DynamicValue, err := tfprotov5.NewDynamicValue(testLinkedResourceProto5Type, testLinkedResourceProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_attribute_one": resourceschema.StringAttribute{ + Required: true, + }, + "test_attribute_two": resourceschema.BoolAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceIdentityProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testLinkedResourceIdentityProto5Value := tftypes.NewValue(testLinkedResourceIdentityProto5Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testLinkedResourceIdentityProto5DynamicValue, err := tfprotov5.NewDynamicValue(testLinkedResourceIdentityProto5Type, testLinkedResourceIdentityProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testCases := map[string]struct { input *fwserver.PlanActionResponse expected *tfprotov5.PlanActionResponse @@ -36,8 +93,71 @@ func TestPlanActionResponse(t *testing.T) { expected: nil, }, "empty": { - input: &fwserver.PlanActionResponse{}, - expected: &tfprotov5.PlanActionResponse{}, + input: &fwserver.PlanActionResponse{}, + expected: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{}, + }, + }, + "linkedresource": { + input: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanLinkedResourceResponse{ + { + PlannedState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto5Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + expected: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{ + { + PlannedState: &testLinkedResourceProto5DynamicValue, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + }, + }, + }, + "linkedresources": { + input: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanLinkedResourceResponse{ + { + PlannedState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto5Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + PlannedState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + }, + }, + }, + expected: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{ + { + PlannedState: &testLinkedResourceProto5DynamicValue, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + { + PlannedState: &testLinkedResourceProto5DynamicValue, + }, + }, + }, }, "diagnostics": { input: &fwserver.PlanActionResponse{ @@ -47,6 +167,7 @@ func TestPlanActionResponse(t *testing.T) { }, }, expected: &tfprotov5.PlanActionResponse{ + LinkedResources: []*tfprotov5.PlannedLinkedResource{}, Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityWarning, @@ -66,7 +187,8 @@ func TestPlanActionResponse(t *testing.T) { Deferred: testDeferral, }, expected: &tfprotov5.PlanActionResponse{ - Deferred: testProto5Deferred, + Deferred: testProto5Deferred, + LinkedResources: []*tfprotov5.PlannedLinkedResource{}, }, }, } From 6566182de814ce14509319016c219556575aee5a Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 4 Aug 2025 09:24:23 -0400 Subject: [PATCH 11/18] fwserver and tests --- internal/fromproto5/planaction.go | 2 +- internal/fromproto5/planaction_test.go | 4 +- internal/fwserver/server_planaction.go | 49 +-- internal/fwserver/server_planaction_test.go | 440 +++++++++++++++++++- internal/toproto5/planaction_test.go | 4 +- 5 files changed, 449 insertions(+), 50 deletions(-) diff --git a/internal/fromproto5/planaction.go b/internal/fromproto5/planaction.go index 00038def6..4ac7974ea 100644 --- a/internal/fromproto5/planaction.go +++ b/internal/fromproto5/planaction.go @@ -121,7 +121,7 @@ func PlanActionRequest(ctx context.Context, proto5 *tfprotov5.PlanActionRequest, priorIdentity = identityVal } - fw.LinkedResources = append(fw.LinkedResources, &fwserver.PlanLinkedResourceRequest{ + fw.LinkedResources = append(fw.LinkedResources, &fwserver.PlanActionLinkedResourceRequest{ Config: config, PlannedState: plannedState, PriorState: priorState, diff --git a/internal/fromproto5/planaction_test.go b/internal/fromproto5/planaction_test.go index a3881d0d8..5f3465c89 100644 --- a/internal/fromproto5/planaction_test.go +++ b/internal/fromproto5/planaction_test.go @@ -223,7 +223,7 @@ func TestPlanActionRequest(t *testing.T) { Raw: testEmptyProto5Value, Schema: testLifecycleSchemaLinked, }, - LinkedResources: []*fwserver.PlanLinkedResourceRequest{ + LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ { Config: &tfsdk.Config{ Raw: testLinkedResourceProto5Value, @@ -279,7 +279,7 @@ func TestPlanActionRequest(t *testing.T) { Raw: testEmptyProto5Value, Schema: testLifecycleSchemaLinked, }, - LinkedResources: []*fwserver.PlanLinkedResourceRequest{ + LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ { Config: &tfsdk.Config{ Raw: testLinkedResourceProto5Value, diff --git a/internal/fwserver/server_planaction.go b/internal/fwserver/server_planaction.go index e29fdcab4..5c779248f 100644 --- a/internal/fwserver/server_planaction.go +++ b/internal/fwserver/server_planaction.go @@ -21,16 +21,10 @@ type PlanActionRequest struct { ActionSchema fwschema.Schema Action action.Action Config *tfsdk.Config - - // TODO:Actions: Should we introduce another layer on top of this? To protect against index-oob and prevent invalid setting of data? (depending on the action schema) - // - // Could just introduce a new tfsdk.State that is more restricted? tfsdk.LinkedResourceState? - // Theoretically, we also need the action schema itself, since there are different rules for each. - // Should we just let Terraform core handle all the validation themselves? That's how it's done today. - LinkedResources []*PlanLinkedResourceRequest // TODO:Actions: Should this be a pointer? + LinkedResources []*PlanActionLinkedResourceRequest } -type PlanLinkedResourceRequest struct { +type PlanActionLinkedResourceRequest struct { Config *tfsdk.Config PlannedState *tfsdk.Plan PriorState *tfsdk.State @@ -39,13 +33,12 @@ type PlanLinkedResourceRequest struct { // PlanActionResponse is the framework server response for the PlanAction RPC. type PlanActionResponse struct { - Deferred *action.Deferred - Diagnostics diag.Diagnostics - - LinkedResources []*PlanLinkedResourceResponse // TODO:Actions: Should this be a pointer? + Deferred *action.Deferred + Diagnostics diag.Diagnostics + LinkedResources []*PlanActionLinkedResourceResponse } -type PlanLinkedResourceResponse struct { +type PlanActionLinkedResourceResponse struct { PlannedState *tfsdk.State PlannedIdentity *tfsdk.ResourceIdentity } @@ -56,27 +49,10 @@ func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *P return } - // By default, copy over planned state and identity for each linked resource - resp.LinkedResources = make([]*PlanLinkedResourceResponse, len(req.LinkedResources)) + // Copy over planned state and identity to the response for each linked resource as a default plan + resp.LinkedResources = make([]*PlanActionLinkedResourceResponse, len(req.LinkedResources)) for i, lr := range req.LinkedResources { - if lr.PlannedState == nil { - // TODO:Actions: I'm not 100% sure if this is valid enough to be a concern, PlanResourceChange populates this with a null - // value of the resource schema type, but it'd be nice to not have to carry linked resource schemas this far - // if we don't need them. - // - // My current thought is that this isn't needed (a similar check would need to be done on identity). Specifically because - // actions should always be following a linked resource PlanResourceChange call. So this value should always be populated and - // this would more be protecting future logic from panicking if a bug existing in Terraform core or Framework/SDKv2. - resp.Diagnostics.AddError( - "Invalid PlannedState for Linked Resource", - "An unexpected error was encountered when planning an action with linked resources. "+ - fmt.Sprintf("Linked resource planned state was nil when received in the protocol, index: %d.\n\n", i)+ - "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.", - ) - return - } - - resp.LinkedResources[i] = &PlanLinkedResourceResponse{ + resp.LinkedResources[i] = &PlanActionLinkedResourceResponse{ PlannedState: planToState(*lr.PlannedState), } @@ -127,8 +103,6 @@ func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *P } } - // TODO:Actions: Should we add support for schema plan modifiers? Technically you could re-use any framework plan modifier - // implementations from the "resource/schema/planmodifier" package if actionWithModifyPlan, ok := req.Action.(action.ActionWithModifyPlan); ok { logging.FrameworkTrace(ctx, "Action implements ActionWithModifyPlan") @@ -172,15 +146,14 @@ func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *P resp.Diagnostics = modifyPlanResp.Diagnostics resp.Deferred = modifyPlanResp.Deferred - // TODO:Actions: improve the error message if len(resp.LinkedResources) != len(modifyPlanResp.LinkedResources) { resp.Diagnostics.AddError( "Invalid Linked Resource Plan", "An unexpected error was encountered when planning an action with linked resources. "+ fmt.Sprintf( - "The number of linked resources planned cannot change, expected: %d, got: %d\n\n", - len(resp.LinkedResources), + "The number of linked resources produced by the action plan cannot change: %d linked resource(s) were produced in the plan, expected %d\n\n", len(modifyPlanResp.LinkedResources), + len(resp.LinkedResources), )+ "This is always a problem with the provider and should be reported to the provider developer.", ) diff --git a/internal/fwserver/server_planaction_test.go b/internal/fwserver/server_planaction_test.go index 70a5a9e7a..2a86d5be2 100644 --- a/internal/fwserver/server_planaction_test.go +++ b/internal/fwserver/server_planaction_test.go @@ -14,7 +14,10 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -41,6 +44,42 @@ func TestServerPlanAction(t *testing.T) { }, } + testEmptyActionSchema := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{}, + } + + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_computed": resourceschema.StringAttribute{ + Computed: true, + }, + "test_required": resourceschema.StringAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceSchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_computed": tftypes.String, + "test_required": tftypes.String, + }, + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testLinkedResourceIdentitySchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + testUnlinkedConfig := &tfsdk.Config{ Raw: testConfigValue, Schema: testUnlinkedSchema, @@ -71,7 +110,7 @@ func TestServerPlanAction(t *testing.T) { Action: &testprovider.Action{}, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, }, }, "request-client-capabilities-deferral-allowed": { @@ -98,7 +137,7 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, }, }, "request-config": { @@ -123,7 +162,132 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, + }, + }, + "request-linkedresources": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + Action: &testprovider.ActionWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + var linkedResourceIdentityData struct { + TestID types.String `tfsdk:"test_id"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Identity.Get(ctx, &linkedResourceIdentityData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceIdentityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"id-123\", got: %s", linkedResourceIdentityData.TestID), + ) + return + } + }, + }, + }, + expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ + { + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, }, }, "action-configure-data": { @@ -162,7 +326,7 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, }, }, "response-deferral-automatic": { @@ -190,7 +354,7 @@ func TestServerPlanAction(t *testing.T) { ClientCapabilities: testDeferralAllowed, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, Deferred: &action.Deferred{Reason: action.DeferredReasonProviderConfigUnknown}, }, }, @@ -219,7 +383,7 @@ func TestServerPlanAction(t *testing.T) { ClientCapabilities: testDeferralAllowed, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, Deferred: &action.Deferred{Reason: action.DeferredReasonAbsentPrereq}, }, }, @@ -238,7 +402,7 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, Diagnostics: diag.Diagnostics{ diag.NewWarningDiagnostic( "warning summary", @@ -251,6 +415,268 @@ func TestServerPlanAction(t *testing.T) { }, }, }, + "response-linkedresources": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + Action: &testprovider.ActionWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + // Should be copied over from request + if len(resp.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected resp.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(resp.LinkedResources[0].Plan.SetAttribute(ctx, path.Root("test_computed"), "new-plan-value")...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.LinkedResources[0].Identity.SetAttribute(ctx, path.Root("test_id"), "new-id-123")...) + if resp.Diagnostics.HasError() { + return + } + }, + }, + }, + expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ + { + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-plan-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + }, + "response-linkedresources-removed": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + Action: &testprovider.ActionWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + resp.LinkedResources = make([]action.ModifyPlanResponseLinkedResource, 0) + }, + }, + }, + expectedResponse: &fwserver.PlanActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Linked Resource Plan", + "An unexpected error was encountered when planning an action with linked resources. "+ + "The number of linked resources produced by the action plan cannot change: 0 linked resource(s) were produced in the plan, expected 1\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.", + ), + }, + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ + { + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + }, + "response-linkedresources-added": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + Action: &testprovider.ActionWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + resp.LinkedResources = append(resp.LinkedResources, action.ModifyPlanResponseLinkedResource{}) + }, + }, + }, + expectedResponse: &fwserver.PlanActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Linked Resource Plan", + "An unexpected error was encountered when planning an action with linked resources. "+ + "The number of linked resources produced by the action plan cannot change: 3 linked resource(s) were produced in the plan, expected 2\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.", + ), + }, + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ + { + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + }, } for name, testCase := range testCases { diff --git a/internal/toproto5/planaction_test.go b/internal/toproto5/planaction_test.go index 705ebfbde..3d52e3182 100644 --- a/internal/toproto5/planaction_test.go +++ b/internal/toproto5/planaction_test.go @@ -100,7 +100,7 @@ func TestPlanActionResponse(t *testing.T) { }, "linkedresource": { input: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanLinkedResourceResponse{ + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ { PlannedState: &tfsdk.State{ Raw: testLinkedResourceProto5Value, @@ -126,7 +126,7 @@ func TestPlanActionResponse(t *testing.T) { }, "linkedresources": { input: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanLinkedResourceResponse{ + LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ { PlannedState: &tfsdk.State{ Raw: testLinkedResourceProto5Value, From a319d238ffd4754dddf57ab807d0ee4d56af46ff Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 4 Aug 2025 15:03:58 -0400 Subject: [PATCH 12/18] renames --- internal/fromproto5/planaction.go | 2 +- internal/fromproto5/planaction_test.go | 4 +-- internal/fwserver/server_planaction.go | 12 ++++----- internal/fwserver/server_planaction_test.go | 30 ++++++++++----------- internal/toproto5/planaction_test.go | 4 +-- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/internal/fromproto5/planaction.go b/internal/fromproto5/planaction.go index 4ac7974ea..29e405a4b 100644 --- a/internal/fromproto5/planaction.go +++ b/internal/fromproto5/planaction.go @@ -121,7 +121,7 @@ func PlanActionRequest(ctx context.Context, proto5 *tfprotov5.PlanActionRequest, priorIdentity = identityVal } - fw.LinkedResources = append(fw.LinkedResources, &fwserver.PlanActionLinkedResourceRequest{ + fw.LinkedResources = append(fw.LinkedResources, &fwserver.PlanActionRequestLinkedResource{ Config: config, PlannedState: plannedState, PriorState: priorState, diff --git a/internal/fromproto5/planaction_test.go b/internal/fromproto5/planaction_test.go index 5f3465c89..c1c60cadc 100644 --- a/internal/fromproto5/planaction_test.go +++ b/internal/fromproto5/planaction_test.go @@ -223,7 +223,7 @@ func TestPlanActionRequest(t *testing.T) { Raw: testEmptyProto5Value, Schema: testLifecycleSchemaLinked, }, - LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ + LinkedResources: []*fwserver.PlanActionRequestLinkedResource{ { Config: &tfsdk.Config{ Raw: testLinkedResourceProto5Value, @@ -279,7 +279,7 @@ func TestPlanActionRequest(t *testing.T) { Raw: testEmptyProto5Value, Schema: testLifecycleSchemaLinked, }, - LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ + LinkedResources: []*fwserver.PlanActionRequestLinkedResource{ { Config: &tfsdk.Config{ Raw: testLinkedResourceProto5Value, diff --git a/internal/fwserver/server_planaction.go b/internal/fwserver/server_planaction.go index 5c779248f..41dbc4a20 100644 --- a/internal/fwserver/server_planaction.go +++ b/internal/fwserver/server_planaction.go @@ -21,10 +21,10 @@ type PlanActionRequest struct { ActionSchema fwschema.Schema Action action.Action Config *tfsdk.Config - LinkedResources []*PlanActionLinkedResourceRequest + LinkedResources []*PlanActionRequestLinkedResource } -type PlanActionLinkedResourceRequest struct { +type PlanActionRequestLinkedResource struct { Config *tfsdk.Config PlannedState *tfsdk.Plan PriorState *tfsdk.State @@ -35,10 +35,10 @@ type PlanActionLinkedResourceRequest struct { type PlanActionResponse struct { Deferred *action.Deferred Diagnostics diag.Diagnostics - LinkedResources []*PlanActionLinkedResourceResponse + LinkedResources []*PlanActionResponseLinkedResource } -type PlanActionLinkedResourceResponse struct { +type PlanActionResponseLinkedResource struct { PlannedState *tfsdk.State PlannedIdentity *tfsdk.ResourceIdentity } @@ -50,9 +50,9 @@ func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *P } // Copy over planned state and identity to the response for each linked resource as a default plan - resp.LinkedResources = make([]*PlanActionLinkedResourceResponse, len(req.LinkedResources)) + resp.LinkedResources = make([]*PlanActionResponseLinkedResource, len(req.LinkedResources)) for i, lr := range req.LinkedResources { - resp.LinkedResources[i] = &PlanActionLinkedResourceResponse{ + resp.LinkedResources[i] = &PlanActionResponseLinkedResource{ PlannedState: planToState(*lr.PlannedState), } diff --git a/internal/fwserver/server_planaction_test.go b/internal/fwserver/server_planaction_test.go index 2a86d5be2..feba37608 100644 --- a/internal/fwserver/server_planaction_test.go +++ b/internal/fwserver/server_planaction_test.go @@ -110,7 +110,7 @@ func TestServerPlanAction(t *testing.T) { Action: &testprovider.Action{}, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, }, }, "request-client-capabilities-deferral-allowed": { @@ -137,7 +137,7 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, }, }, "request-config": { @@ -162,7 +162,7 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, }, }, "request-linkedresources": { @@ -171,7 +171,7 @@ func TestServerPlanAction(t *testing.T) { }, request: &fwserver.PlanActionRequest{ ActionSchema: testEmptyActionSchema, - LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ + LinkedResources: []*fwserver.PlanActionRequestLinkedResource{ { Config: &tfsdk.Config{ Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ @@ -271,7 +271,7 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{ { PlannedState: &tfsdk.State{ Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ @@ -326,7 +326,7 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, }, }, "response-deferral-automatic": { @@ -354,7 +354,7 @@ func TestServerPlanAction(t *testing.T) { ClientCapabilities: testDeferralAllowed, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, Deferred: &action.Deferred{Reason: action.DeferredReasonProviderConfigUnknown}, }, }, @@ -383,7 +383,7 @@ func TestServerPlanAction(t *testing.T) { ClientCapabilities: testDeferralAllowed, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, Deferred: &action.Deferred{Reason: action.DeferredReasonAbsentPrereq}, }, }, @@ -402,7 +402,7 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{}, + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, Diagnostics: diag.Diagnostics{ diag.NewWarningDiagnostic( "warning summary", @@ -421,7 +421,7 @@ func TestServerPlanAction(t *testing.T) { }, request: &fwserver.PlanActionRequest{ ActionSchema: testEmptyActionSchema, - LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ + LinkedResources: []*fwserver.PlanActionRequestLinkedResource{ { Config: &tfsdk.Config{ Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ @@ -472,7 +472,7 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{ { PlannedState: &tfsdk.State{ Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ @@ -497,7 +497,7 @@ func TestServerPlanAction(t *testing.T) { }, request: &fwserver.PlanActionRequest{ ActionSchema: testEmptyActionSchema, - LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ + LinkedResources: []*fwserver.PlanActionRequestLinkedResource{ { Config: &tfsdk.Config{ Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ @@ -543,7 +543,7 @@ func TestServerPlanAction(t *testing.T) { "This is always a problem with the provider and should be reported to the provider developer.", ), }, - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{ { PlannedState: &tfsdk.State{ Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ @@ -568,7 +568,7 @@ func TestServerPlanAction(t *testing.T) { }, request: &fwserver.PlanActionRequest{ ActionSchema: testEmptyActionSchema, - LinkedResources: []*fwserver.PlanActionLinkedResourceRequest{ + LinkedResources: []*fwserver.PlanActionRequestLinkedResource{ { Config: &tfsdk.Config{ Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ @@ -643,7 +643,7 @@ func TestServerPlanAction(t *testing.T) { "This is always a problem with the provider and should be reported to the provider developer.", ), }, - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{ { PlannedState: &tfsdk.State{ Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ diff --git a/internal/toproto5/planaction_test.go b/internal/toproto5/planaction_test.go index 3d52e3182..0290eed2d 100644 --- a/internal/toproto5/planaction_test.go +++ b/internal/toproto5/planaction_test.go @@ -100,7 +100,7 @@ func TestPlanActionResponse(t *testing.T) { }, "linkedresource": { input: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{ { PlannedState: &tfsdk.State{ Raw: testLinkedResourceProto5Value, @@ -126,7 +126,7 @@ func TestPlanActionResponse(t *testing.T) { }, "linkedresources": { input: &fwserver.PlanActionResponse{ - LinkedResources: []*fwserver.PlanActionLinkedResourceResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{ { PlannedState: &tfsdk.State{ Raw: testLinkedResourceProto5Value, From 17ab6fdde3505053860aadaedf3760c942b0b9db Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 5 Aug 2025 17:12:11 -0400 Subject: [PATCH 13/18] invoke impl for linked resources --- action/invoke.go | 19 +- action/schema/schema_type.go | 2 +- internal/fromproto5/invokeaction.go | 83 +- internal/fromproto5/invokeaction_test.go | 316 ++++- internal/fwserver/server_invokeaction.go | 114 +- internal/fwserver/server_invokeaction_test.go | 631 ++++++++- internal/proto5server/server_invokeaction.go | 75 +- .../proto5server/server_invokeaction_test.go | 1142 ++++++++++++++++- internal/toproto5/invoke_action_event.go | 27 +- internal/toproto5/invoke_action_event_test.go | 123 ++ 10 files changed, 2468 insertions(+), 64 deletions(-) diff --git a/action/invoke.go b/action/invoke.go index 65be360fb..ceed7ae65 100644 --- a/action/invoke.go +++ b/action/invoke.go @@ -14,7 +14,15 @@ type InvokeRequest struct { // Config is the configuration the user supplied for the action. Config tfsdk.Config - // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented + LinkedResources []InvokeRequestLinkedResource +} + +// TODO:Actions: docs, change name of this struct :? +type InvokeRequestLinkedResource struct { + Config tfsdk.Config + State tfsdk.State + Identity *tfsdk.ResourceIdentity + Plan tfsdk.Plan } // InvokeResponse represents a response to an InvokeRequest. An @@ -28,13 +36,20 @@ type InvokeResponse struct { // generated. Diagnostics diag.Diagnostics + LinkedResources []InvokeResponseLinkedResource + // SendProgress will immediately send a progress update to Terraform core during action invocation. // This function is provided by the framework and can be called multiple times while action logic is running. // // TODO:Actions: More documentation about when you should use this / when you shouldn't SendProgress func(event InvokeProgressEvent) +} - // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented +// TODO:Actions: docs, change name of this struct :? +type InvokeResponseLinkedResource struct { + State tfsdk.State + Identity *tfsdk.ResourceIdentity + RequiresReplace bool // TODO:Actions: Document that this can only be present when diagnostics exist } // InvokeProgressEvent is the event returned to Terraform while an action is being invoked. diff --git a/action/schema/schema_type.go b/action/schema/schema_type.go index 3d3184a6c..5b3872520 100644 --- a/action/schema/schema_type.go +++ b/action/schema/schema_type.go @@ -10,7 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" ) -// TODO:Actions: Implement lifecycle and linked schemas +// TODO:Actions: Implement linked schemas // // SchemaType is the interface that an action schema type must implement. Action // schema types are statically definined in the protocol, so all implementations diff --git a/internal/fromproto5/invokeaction.go b/internal/fromproto5/invokeaction.go index 85690b707..7df901093 100644 --- a/internal/fromproto5/invokeaction.go +++ b/internal/fromproto5/invokeaction.go @@ -5,6 +5,7 @@ package fromproto5 import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-go/tfprotov5" @@ -12,10 +13,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) // InvokeActionRequest returns the *fwserver.InvokeActionRequest equivalent of a *tfprotov5.InvokeActionRequest. -func InvokeActionRequest(ctx context.Context, proto5 *tfprotov5.InvokeActionRequest, reqAction action.Action, actionSchema fwschema.Schema) (*fwserver.InvokeActionRequest, diag.Diagnostics) { +func InvokeActionRequest(ctx context.Context, proto5 *tfprotov5.InvokeActionRequest, reqAction action.Action, actionSchema fwschema.Schema, linkedResourceSchemas []fwschema.Schema, linkedResourceIdentitySchemas []fwschema.Schema) (*fwserver.InvokeActionRequest, diag.Diagnostics) { if proto5 == nil { return nil, nil } @@ -47,7 +49,84 @@ func InvokeActionRequest(ctx context.Context, proto5 *tfprotov5.InvokeActionRequ fw.Config = config - // TODO:Actions: Here we need to retrieve linked resource data + if len(proto5.LinkedResources) != len(linkedResourceSchemas) { + diags.AddError( + "Mismatched Linked Resources in InvokeAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + fmt.Sprintf( + "Received %d linked resource(s), but the provider was expecting %d linked resource(s).", + len(proto5.LinkedResources), + len(linkedResourceSchemas), + ), + ) + + return nil, diags + } + + // MAINTAINER NOTE: The number of identity schemas should always be in sync (if not supported, will have nil), + // so this error check is more for panic prevention. + if len(proto5.LinkedResources) != len(linkedResourceIdentitySchemas) { + diags.AddError( + "Mismatched Linked Resources in InvokeAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + fmt.Sprintf( + "Received %d linked resource(s), but the provider was expecting %d linked resource(s).", + len(proto5.LinkedResources), + len(linkedResourceIdentitySchemas), + ), + ) + + return nil, diags + } + + for i, linkedResource := range proto5.LinkedResources { + schema := linkedResourceSchemas[i] + identitySchema := linkedResourceIdentitySchemas[i] + + // Config + config, configDiags := Config(ctx, linkedResource.Config, schema) + diags.Append(configDiags...) + + // Prior state + priorState, priorStateDiags := State(ctx, linkedResource.PriorState, schema) + diags.Append(priorStateDiags...) + + // Planned state (plan) + plannedState, plannedStateDiags := Plan(ctx, linkedResource.PlannedState, schema) + diags.Append(plannedStateDiags...) + + // Planned identity + var plannedIdentity *tfsdk.ResourceIdentity + if linkedResource.PlannedIdentity != nil { + if identitySchema == nil { + // MAINTAINER NOTE: Not all linked resources support identity, so it's valid for an identity schema to be nil. However, + // it's not valid for Terraform core to send an identity for a linked resource that doesn't support one. This would likely indicate + // that there is a bug in the definition of the linked resources (not including an identity schema when it is supported), or a bug in + // either Terraform core/Framework. + diags.AddError( + "Unable to Convert Linked Resource Identity", + "An unexpected error was encountered when converting a linked resource identity from the protocol type. "+ + fmt.Sprintf("Linked resource (at index %d) contained identity data, but the resource doesn't support identity.\n\n", i)+ + "This is always a problem with the provider and should be reported to the provider developer.", + ) + return nil, diags + } + + identityVal, plannedIdentityDiags := ResourceIdentity(ctx, linkedResource.PlannedIdentity, identitySchema) + diags.Append(plannedIdentityDiags...) + + plannedIdentity = identityVal + } + + fw.LinkedResources = append(fw.LinkedResources, &fwserver.InvokeActionRequestLinkedResource{ + Config: config, + PlannedState: plannedState, + PriorState: priorState, + PlannedIdentity: plannedIdentity, + }) + } return fw, diags } diff --git a/internal/fromproto5/invokeaction_test.go b/internal/fromproto5/invokeaction_test.go index 6f74fcf83..6e8882a6e 100644 --- a/internal/fromproto5/invokeaction_test.go +++ b/internal/fromproto5/invokeaction_test.go @@ -12,17 +12,27 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-framework/action" - "github.com/hashicorp/terraform-plugin-framework/action/schema" + actionschema "github.com/hashicorp/terraform-plugin-framework/action/schema" "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/fwserver" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) func TestInvokeActionRequest(t *testing.T) { t.Parallel() + testEmptyProto5Value := tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}, map[string]tftypes.Value{}) + + testEmptyProto5DynamicValue, err := tfprotov5.NewDynamicValue(tftypes.Object{}, testEmptyProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + testProto5Type := tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "test_attribute": tftypes.String, @@ -39,21 +49,83 @@ func TestInvokeActionRequest(t *testing.T) { t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) } - testUnlinkedSchema := schema.UnlinkedSchema{ - Attributes: map[string]schema.Attribute{ - "test_attribute": schema.StringAttribute{ + testLinkedResourceProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute_one": tftypes.String, + "test_attribute_two": tftypes.Bool, + }, + } + + testLinkedResourceProto5Value := tftypes.NewValue(testLinkedResourceProto5Type, map[string]tftypes.Value{ + "test_attribute_one": tftypes.NewValue(tftypes.String, "test-value-1"), + "test_attribute_two": tftypes.NewValue(tftypes.Bool, true), + }) + + testLinkedResourceProto5DynamicValue, err := tfprotov5.NewDynamicValue(testLinkedResourceProto5Type, testLinkedResourceProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_attribute_one": resourceschema.StringAttribute{ + Required: true, + }, + "test_attribute_two": resourceschema.BoolAttribute{ Required: true, }, }, } + testLinkedResourceIdentityProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testLinkedResourceIdentityProto5Value := tftypes.NewValue(testLinkedResourceIdentityProto5Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testLinkedResourceIdentityProto5DynamicValue, err := tfprotov5.NewDynamicValue(testLinkedResourceIdentityProto5Type, testLinkedResourceIdentityProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testUnlinkedSchema := actionschema.UnlinkedSchema{ + Attributes: map[string]actionschema.Attribute{ + "test_attribute": actionschema.StringAttribute{ + Required: true, + }, + }, + } + + testLifecycleSchemaLinked := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.LinkedResource{ + TypeName: "test_linked_resource", + }, + } + testCases := map[string]struct { - input *tfprotov5.InvokeActionRequest - actionSchema fwschema.Schema - actionImpl action.Action - providerMetaSchema fwschema.Schema - expected *fwserver.InvokeActionRequest - expectedDiagnostics diag.Diagnostics + input *tfprotov5.InvokeActionRequest + actionSchema fwschema.Schema + actionImpl action.Action + linkedResourceSchemas []fwschema.Schema + linkedResourceIdentitySchemas []fwschema.Schema + providerMetaSchema fwschema.Schema + expected *fwserver.InvokeActionRequest + expectedDiagnostics diag.Diagnostics }{ "nil": { input: nil, @@ -100,14 +172,234 @@ func TestInvokeActionRequest(t *testing.T) { ActionSchema: testUnlinkedSchema, }, }, + "linkedresource": { + input: &tfprotov5.InvokeActionRequest{ + Config: &testEmptyProto5DynamicValue, + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + }, + actionSchema: testLifecycleSchemaLinked, + expected: &fwserver.InvokeActionRequest{ + ActionSchema: testLifecycleSchemaLinked, + Config: &tfsdk.Config{ + Raw: testEmptyProto5Value, + Schema: testLifecycleSchemaLinked, + }, + LinkedResources: []*fwserver.InvokeActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto5Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + }, + "linkedresources": { + input: &tfprotov5.InvokeActionRequest{ + Config: &testEmptyProto5DynamicValue, + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + nil, // Second resource doesn't have an identity + }, + actionSchema: testLifecycleSchemaLinked, + expected: &fwserver.InvokeActionRequest{ + ActionSchema: testLifecycleSchemaLinked, + Config: &tfsdk.Config{ + Raw: testEmptyProto5Value, + Schema: testLifecycleSchemaLinked, + }, + LinkedResources: []*fwserver.InvokeActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto5Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + }, + }, + }, + }, + "linkedresources-mismatched-number-of-schemas": { + input: &tfprotov5.InvokeActionRequest{ + Config: &testEmptyProto5DynamicValue, + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + nil, // Second resource doesn't have an identity + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Mismatched Linked Resources in InvokeAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + "Received 2 linked resource(s), but the provider was expecting 1 linked resource(s).", + ), + }, + }, + "linkedresources-mismatched-number-of-identity-schemas": { + input: &tfprotov5.InvokeActionRequest{ + Config: &testEmptyProto5DynamicValue, + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Mismatched Linked Resources in InvokeAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + "Received 2 linked resource(s), but the provider was expecting 1 linked resource(s).", + ), + }, + }, + "linkedresources-no-identity-schema": { + input: &tfprotov5.InvokeActionRequest{ + Config: &testEmptyProto5DynamicValue, + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: &testLinkedResourceProto5DynamicValue, + PlannedState: &testLinkedResourceProto5DynamicValue, + Config: &testLinkedResourceProto5DynamicValue, + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + nil, + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Linked Resource Identity", + "An unexpected error was encountered when converting a linked resource identity from the protocol type. "+ + "Linked resource (at index 0) contained identity data, but the resource doesn't support identity.\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.", + ), + }, + }, } for name, testCase := range testCases { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto5.InvokeActionRequest(context.Background(), testCase.input, testCase.actionImpl, testCase.actionSchema) - + got, diags := fromproto5.InvokeActionRequest(context.Background(), testCase.input, testCase.actionImpl, testCase.actionSchema, testCase.linkedResourceSchemas, testCase.linkedResourceIdentitySchemas) if diff := cmp.Diff(got, testCase.expected); diff != "" { t.Errorf("unexpected difference: %s", diff) } diff --git a/internal/fwserver/server_invokeaction.go b/internal/fwserver/server_invokeaction.go index 153ac6f06..1c391ee88 100644 --- a/internal/fwserver/server_invokeaction.go +++ b/internal/fwserver/server_invokeaction.go @@ -5,6 +5,7 @@ package fwserver import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -16,17 +17,32 @@ import ( // InvokeActionRequest is the framework server request for the InvokeAction RPC. type InvokeActionRequest struct { - Action action.Action - ActionSchema fwschema.Schema - Config *tfsdk.Config + Action action.Action + ActionSchema fwschema.Schema + Config *tfsdk.Config + LinkedResources []*InvokeActionRequestLinkedResource +} + +type InvokeActionRequestLinkedResource struct { + Config *tfsdk.Config + PlannedState *tfsdk.Plan + PriorState *tfsdk.State + PlannedIdentity *tfsdk.ResourceIdentity } // InvokeActionEventsStream is the framework server stream for the InvokeAction RPC. type InvokeActionResponse struct { // ProgressEvents is a channel provided by the consuming proto{5/6}server implementation // that allows the provider developers to return progress events while the action is being invoked. - ProgressEvents chan InvokeProgressEvent - Diagnostics diag.Diagnostics + ProgressEvents chan InvokeProgressEvent + Diagnostics diag.Diagnostics + LinkedResources []*InvokeActionResponseLinkedResource +} + +type InvokeActionResponseLinkedResource struct { + NewState *tfsdk.State + NewIdentity *tfsdk.ResourceIdentity + RequiresReplace bool } type InvokeProgressEvent struct { @@ -73,10 +89,58 @@ func (s *Server) InvokeAction(ctx context.Context, req *InvokeActionRequest, res } invokeReq := action.InvokeRequest{ - Config: *req.Config, + Config: *req.Config, + LinkedResources: make([]action.InvokeRequestLinkedResource, len(req.LinkedResources)), } invokeResp := action.InvokeResponse{ - SendProgress: resp.SendProgress, + SendProgress: resp.SendProgress, + LinkedResources: make([]action.InvokeResponseLinkedResource, len(req.LinkedResources)), + } + + // Pass-through the new state and identity to the response for each linked resource + resp.LinkedResources = make([]*InvokeActionResponseLinkedResource, len(req.LinkedResources)) + for i, lr := range req.LinkedResources { + // Initialize new state as a null object + newState := &tfsdk.State{ + Schema: lr.PlannedState.Schema, + Raw: tftypes.NewValue(lr.PlannedState.Schema.Type().TerraformType(ctx), nil), + } + + // Depending on when the action is run, prior state will either be the last read of + // the resource (which could be null, if creating) or the final new state from ApplyResourceChange. + // + // If we have a prior state, use that as the default new state. + if lr.PriorState != nil { + newState = lr.PriorState + } + + // Copy new state, config, plan and identity + resp.LinkedResources[i] = &InvokeActionResponseLinkedResource{ + NewState: newState, + } + invokeReq.LinkedResources[i] = action.InvokeRequestLinkedResource{ + Config: *lr.Config, + State: *newState, + Plan: *lr.PlannedState, + } + invokeResp.LinkedResources[i] = action.InvokeResponseLinkedResource{ + State: *newState, + } + + if lr.PlannedIdentity != nil { + resp.LinkedResources[i].NewIdentity = &tfsdk.ResourceIdentity{ + Schema: lr.PlannedIdentity.Schema, + Raw: lr.PlannedIdentity.Raw.Copy(), + } + invokeReq.LinkedResources[i].Identity = &tfsdk.ResourceIdentity{ + Schema: lr.PlannedIdentity.Schema, + Raw: lr.PlannedIdentity.Raw.Copy(), + } + invokeResp.LinkedResources[i].Identity = &tfsdk.ResourceIdentity{ + Schema: lr.PlannedIdentity.Schema, + Raw: lr.PlannedIdentity.Raw.Copy(), + } + } } logging.FrameworkTrace(ctx, "Calling provider defined Action Invoke") @@ -84,4 +148,40 @@ func (s *Server) InvokeAction(ctx context.Context, req *InvokeActionRequest, res logging.FrameworkTrace(ctx, "Called provider defined Action Invoke") resp.Diagnostics = invokeResp.Diagnostics + + if len(resp.LinkedResources) != len(invokeResp.LinkedResources) { + resp.Diagnostics.AddError( + "Invalid Linked Resource State", + "An unexpected error was encountered when invoking an action with linked resources. "+ + fmt.Sprintf( + "The number of linked resource states produced by the action invoke cannot change: %d linked resource(s) were planned, expected %d\n\n", + len(invokeResp.LinkedResources), + len(resp.LinkedResources), + )+ + "This is always a problem with the provider and should be reported to the provider developer.", + ) + return + } + + processingDiags := make(diag.Diagnostics, 0) + for i, newLinkedResource := range invokeResp.LinkedResources { + resp.LinkedResources[i].NewState = &newLinkedResource.State + resp.LinkedResources[i].NewIdentity = newLinkedResource.Identity + resp.LinkedResources[i].RequiresReplace = newLinkedResource.RequiresReplace + + if !resp.Diagnostics.HasError() && resp.LinkedResources[i].RequiresReplace { + processingDiags.AddError( + "Invalid Linked Resource Replacement", + "An unexpected error was encountered when invoking an action with linked resources. "+ + fmt.Sprintf("The Terraform Provider returned a linked resource (at index %d) that "+ + "indicates that it needs to be replaced, but no error diagnostics were returned.\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.", i), + ) + + // Continue processing the rest of the linked resources + continue + } + } + + resp.Diagnostics.Append(processingDiags...) } diff --git a/internal/fwserver/server_invokeaction_test.go b/internal/fwserver/server_invokeaction_test.go index ece66c657..6298c7d3e 100644 --- a/internal/fwserver/server_invokeaction_test.go +++ b/internal/fwserver/server_invokeaction_test.go @@ -14,7 +14,10 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -41,6 +44,42 @@ func TestServerInvokeAction(t *testing.T) { }, } + testEmptyActionSchema := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{}, + } + + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_computed": resourceschema.StringAttribute{ + Computed: true, + }, + "test_required": resourceschema.StringAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceSchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_computed": tftypes.String, + "test_required": tftypes.String, + }, + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testLinkedResourceIdentitySchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + testUnlinkedConfig := &tfsdk.Config{ Raw: testConfigValue, Schema: testUnlinkedSchema, @@ -72,7 +111,9 @@ func TestServerInvokeAction(t *testing.T) { }, }, }, - expectedResponse: &fwserver.InvokeActionResponse{}, + expectedResponse: &fwserver.InvokeActionResponse{ + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{}, + }, }, "request-config": { server: &fwserver.Server{ @@ -95,7 +136,134 @@ func TestServerInvokeAction(t *testing.T) { }, }, }, - expectedResponse: &fwserver.InvokeActionResponse{}, + expectedResponse: &fwserver.InvokeActionResponse{ + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{}, + }, + }, + "request-linkedresources": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.InvokeActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.InvokeActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + Action: &testprovider.Action{ + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + var linkedResourceIdentityData struct { + TestID types.String `tfsdk:"test_id"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Identity.Get(ctx, &linkedResourceIdentityData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceIdentityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"id-123\", got: %s", linkedResourceIdentityData.TestID), + ) + return + } + }, + }, + }, + expectedResponse: &fwserver.InvokeActionResponse{ + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{ + { + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, }, "action-configure-data": { server: &fwserver.Server{ @@ -134,7 +302,9 @@ func TestServerInvokeAction(t *testing.T) { }, }, }, - expectedResponse: &fwserver.InvokeActionResponse{}, + expectedResponse: &fwserver.InvokeActionResponse{ + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{}, + }, }, "response-diagnostics": { server: &fwserver.Server{ @@ -151,6 +321,7 @@ func TestServerInvokeAction(t *testing.T) { }, }, expectedResponse: &fwserver.InvokeActionResponse{ + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{}, Diagnostics: diag.Diagnostics{ diag.NewWarningDiagnostic( "warning summary", @@ -163,6 +334,460 @@ func TestServerInvokeAction(t *testing.T) { }, }, }, + "response-linkedresources": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.InvokeActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.InvokeActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + Action: &testprovider.Action{ + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + // Should be copied over from request + if len(resp.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected resp.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(resp.LinkedResources[0].State.SetAttribute(ctx, path.Root("test_computed"), "new-state-value")...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.LinkedResources[0].Identity.SetAttribute(ctx, path.Root("test_id"), "new-id-123")...) + if resp.Diagnostics.HasError() { + return + } + }, + }, + }, + expectedResponse: &fwserver.InvokeActionResponse{ + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{ + { + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + }, + "response-linkedresources-removed": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.InvokeActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.InvokeActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + Action: &testprovider.Action{ + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + resp.LinkedResources = make([]action.InvokeResponseLinkedResource, 0) + }, + }, + }, + expectedResponse: &fwserver.InvokeActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Linked Resource State", + "An unexpected error was encountered when invoking an action with linked resources. "+ + "The number of linked resource states produced by the action invoke cannot change: 0 linked resource(s) were planned, expected 1\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.", + ), + }, + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{ + { + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + }, + "response-linkedresources-added": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.InvokeActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.InvokeActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + Action: &testprovider.Action{ + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + resp.LinkedResources = append(resp.LinkedResources, action.InvokeResponseLinkedResource{}) + }, + }, + }, + expectedResponse: &fwserver.InvokeActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Linked Resource State", + "An unexpected error was encountered when invoking an action with linked resources. "+ + "The number of linked resource states produced by the action invoke cannot change: 3 linked resource(s) were planned, expected 2\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.", + ), + }, + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{ + { + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: tftypes.NewValue(testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + }, + "response-linkedresources-valid-replacement": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.InvokeActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.InvokeActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + }, + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + }, + }, + Action: &testprovider.Action{ + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + resp.LinkedResources[0].RequiresReplace = true + resp.Diagnostics.AddError("error summary", "error detail") + + resp.Diagnostics.Append(resp.LinkedResources[1].State.SetAttribute(ctx, path.Root("test_computed"), "new-state-value")...) + if resp.Diagnostics.HasError() { + return + } + }, + }, + }, + expectedResponse: &fwserver.InvokeActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "error summary", + "error detail", + ), + }, + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{ + { + RequiresReplace: true, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + }, + { + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + }, + }, + }, + }, + "response-linkedresources-invalid-replacement": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.InvokeActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.InvokeActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + }, + { + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + }, + }, + Action: &testprovider.Action{ + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + // Not allowed to require replacement on a resource without a diagnostic + resp.LinkedResources[0].RequiresReplace = true + + resp.Diagnostics.Append(resp.LinkedResources[1].State.SetAttribute(ctx, path.Root("test_computed"), "new-state-value")...) + }, + }, + }, + expectedResponse: &fwserver.InvokeActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Linked Resource Replacement", + "An unexpected error was encountered when invoking an action with linked resources. "+ + "The Terraform Provider returned a linked resource (at index 0) that "+ + "indicates that it needs to be replaced, but no error diagnostics were returned.\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.", + ), + }, + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{ + { + RequiresReplace: true, + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + }, + { + NewState: &tfsdk.State{ + Raw: tftypes.NewValue(testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testLinkedResourceSchema, + }, + }, + }, + }, + }, } for name, testCase := range testCases { diff --git a/internal/proto5server/server_invokeaction.go b/internal/proto5server/server_invokeaction.go index 216a067b3..74ce98c50 100644 --- a/internal/proto5server/server_invokeaction.go +++ b/internal/proto5server/server_invokeaction.go @@ -5,9 +5,12 @@ package proto5server import ( "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/action/schema" "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/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" @@ -52,7 +55,77 @@ func (s *Server) InvokeAction(ctx context.Context, proto5Req *tfprotov5.InvokeAc return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) } - fwReq, diags := fromproto5.InvokeActionRequest(ctx, proto5Req, action, actionSchema) + lrSchemas := make([]fwschema.Schema, 0) + lrIdentitySchemas := make([]fwschema.Schema, 0) + for _, lrType := range actionSchema.LinkedResourceTypes() { + switch lrType := lrType.(type) { + case schema.RawV5LinkedResource: + // Raw linked resources are not stored on this provider server, so we retrieve the schemas from the + // action definition directly and convert them to framework schemas. + lrSchema, err := fromproto5.ResourceSchema(ctx, lrType.GetSchema()) + if err != nil { + fwResp.Diagnostics.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %q linked resource schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), + ) + + return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) + } + lrSchemas = append(lrSchemas, lrSchema) + + lrIdentitySchema, err := fromproto5.IdentitySchema(ctx, lrType.GetIdentitySchema()) + if err != nil { + fwResp.Diagnostics.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %q linked resource identity schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), + ) + + return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) + } + lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) + case schema.RawV6LinkedResource: + fwResp.Diagnostics.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "The %[1]q linked resource is a protocol v6 resource but the provider is being served using protocol v5.", lrType.GetTypeName()), + ) + + return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) + default: + // Any other linked resource type should be stored on the same provider server as the action, + // so we can just retrieve it via the type name. + lrSchema, diags := s.FrameworkServer.ResourceSchema(ctx, lrType.GetTypeName()) + if diags.HasError() { + fwResp.Diagnostics.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource data from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "The %[1]q linked resource was not found on the provider server.", lrType.GetTypeName()), + ) + + return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) + } + lrSchemas = append(lrSchemas, lrSchema) + + lrIdentitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, lrType.GetTypeName()) + fwResp.Diagnostics.Append(diags...) + if fwResp.Diagnostics.HasError() { + // If the resource is found, the identity schema will only return a diagnostic if the provider implementation + // returns an error from (resource.Resource).IdentitySchema method. + return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) + } + lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) + } + } + + fwReq, diags := fromproto5.InvokeActionRequest(ctx, proto5Req, action, actionSchema, lrSchemas, lrIdentitySchemas) fwResp.Diagnostics.Append(diags...) diff --git a/internal/proto5server/server_invokeaction_test.go b/internal/proto5server/server_invokeaction_test.go index 6d08c770d..b4eddcf4f 100644 --- a/internal/proto5server/server_invokeaction_test.go +++ b/internal/proto5server/server_invokeaction_test.go @@ -11,11 +11,16 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/action" - "github.com/hashicorp/terraform-plugin-framework/action/schema" + actionschema "github.com/hashicorp/terraform-plugin-framework/action/schema" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,40 +33,747 @@ func TestServerInvokeAction(t *testing.T) { }, } - testConfigDynamicValue := testNewDynamicValue(t, testType, map[string]tftypes.Value{ + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_computed": resourceschema.StringAttribute{ + Computed: true, + }, + "test_required": resourceschema.StringAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceSchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_computed": tftypes.String, + "test_required": tftypes.String, + }, + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testLinkedResourceIdentitySchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testActionConfigDynamicValue := testNewDynamicValue(t, testType, map[string]tftypes.Value{ "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), }) - testEmptyDynamicValue := testNewDynamicValue(t, tftypes.Object{}, nil) + testEmptyDynamicValue := testNewDynamicValue(t, tftypes.Object{}, nil) + + testUnlinkedSchema := actionschema.UnlinkedSchema{ + Attributes: map[string]actionschema.Attribute{ + "test_required": actionschema.StringAttribute{ + Required: true, + }, + }, + } + + testLifecycleSchema := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.LinkedResource{ + TypeName: "test_linked_resource", + }, + } + + testLifecycleSchemaRaw := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV5LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov5.Schema { + return &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + IdentitySchema: func() *tfprotov5.ResourceIdentitySchema { + return &tfprotov5.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + { + Name: "test_id", + Type: tftypes.String, + RequiredForImport: true, + }, + }, + } + }, + }, + } + + testLifecycleSchemaRawNoIdentity := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV5LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov5.Schema { + return &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + }, + } + + testCases := map[string]struct { + server *Server + request *tfprotov5.InvokeActionRequest + expectedError error + expectedEvents []tfprotov5.InvokeActionEvent + }{ + "no-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.UnlinkedSchema{} + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{}, + }, + }, + }, + }, + "request-config": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testUnlinkedSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var config struct { + TestRequired types.String `tfsdk:"test_required"` + } + + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + + if config.TestRequired.ValueString() != "test-config-value" { + resp.Diagnostics.AddError("unexpected req.Config value: %s", config.TestRequired.ValueString()) + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testActionConfigDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{}, + }, + }, + }, + }, + "request-linkedresource-no-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.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + }, + }, + }, + "request-linkedresource-with-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) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + var linkedResourceIdentityData struct { + TestID types.String `tfsdk:"test_id"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Identity.Get(ctx, &linkedResourceIdentityData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceIdentityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"id-123\", got: %s", linkedResourceIdentityData.TestID), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + }, + }, + }, + "request-raw-linkedresource-no-identity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchemaRawNoIdentity + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + }, + }, + }, + "request-raw-linkedresource-with-identity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchemaRaw + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + var linkedResourceIdentityData struct { + TestID types.String `tfsdk:"test_id"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } - testUnlinkedSchema := schema.UnlinkedSchema{ - Attributes: map[string]schema.Attribute{ - "test_required": schema.StringAttribute{ - Required: true, + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Identity.Get(ctx, &linkedResourceIdentityData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceIdentityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"id-123\", got: %s", linkedResourceIdentityData.TestID), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + }, }, }, - } - - testCases := map[string]struct { - server *Server - request *tfprotov5.InvokeActionRequest - expectedError error - expectedEvents []tfprotov5.InvokeActionEvent - }{ - "no-schema": { + "response-linkedresource-no-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.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + } + }, + } + }, ActionsMethod: func(_ context.Context) []func() action.Action { return []func() action.Action{ func() action.Action { return &testprovider.Action{ SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { - resp.Schema = schema.UnlinkedSchema{} + resp.Schema = testLifecycleSchema }, MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { resp.TypeName = "test_action" }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + // Should be copied over from request + if len(resp.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected resp.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(resp.LinkedResources[0].State.SetAttribute(ctx, path.Root("test_computed"), "new-state-value")...) + if resp.Diagnostics.HasError() { + return + } + }, } }, } @@ -72,36 +784,85 @@ func TestServerInvokeAction(t *testing.T) { request: &tfprotov5.InvokeActionRequest{ Config: testEmptyDynamicValue, ActionType: "test_action", + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, }, expectedEvents: []tfprotov5.InvokeActionEvent{ { - Type: tfprotov5.CompletedInvokeActionEventType{}, + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, }, }, }, - "request-config": { + "response-linkedresource-with-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) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, ActionsMethod: func(_ context.Context) []func() action.Action { return []func() action.Action{ func() action.Action { return &testprovider.Action{ SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { - resp.Schema = testUnlinkedSchema + resp.Schema = testLifecycleSchema }, MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { resp.TypeName = "test_action" }, InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { - var config struct { - TestRequired types.String `tfsdk:"test_required"` + // Should be copied over from request + if len(resp.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected resp.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) } - resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + resp.Diagnostics.Append(resp.LinkedResources[0].State.SetAttribute(ctx, path.Root("test_computed"), "new-state-value")...) + if resp.Diagnostics.HasError() { + return + } - if config.TestRequired.ValueString() != "test-config-value" { - resp.Diagnostics.AddError("unexpected req.Config value: %s", config.TestRequired.ValueString()) + resp.Diagnostics.Append(resp.LinkedResources[0].Identity.SetAttribute(ctx, path.Root("test_id"), "new-id-123")...) + if resp.Diagnostics.HasError() { + return } }, } @@ -112,12 +873,47 @@ func TestServerInvokeAction(t *testing.T) { }, }, request: &tfprotov5.InvokeActionRequest{ - Config: testConfigDynamicValue, + Config: testEmptyDynamicValue, ActionType: "test_action", + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, }, expectedEvents: []tfprotov5.InvokeActionEvent{ { - Type: tfprotov5.CompletedInvokeActionEventType{}, + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + }, + }, + }, + }, }, }, }, @@ -148,7 +944,7 @@ func TestServerInvokeAction(t *testing.T) { }, }, request: &tfprotov5.InvokeActionRequest{ - Config: testConfigDynamicValue, + Config: testActionConfigDynamicValue, ActionType: "test_action", }, expectedEvents: []tfprotov5.InvokeActionEvent{ @@ -168,7 +964,9 @@ func TestServerInvokeAction(t *testing.T) { }, }, { - Type: tfprotov5.CompletedInvokeActionEventType{}, + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{}, + }, }, }, }, @@ -198,12 +996,13 @@ func TestServerInvokeAction(t *testing.T) { }, }, request: &tfprotov5.InvokeActionRequest{ - Config: testConfigDynamicValue, + Config: testActionConfigDynamicValue, ActionType: "test_action", }, expectedEvents: []tfprotov5.InvokeActionEvent{ { Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{}, Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityWarning, @@ -250,7 +1049,7 @@ func TestServerInvokeAction(t *testing.T) { }, }, request: &tfprotov5.InvokeActionRequest{ - Config: testConfigDynamicValue, + Config: testActionConfigDynamicValue, ActionType: "test_action", }, expectedEvents: []tfprotov5.InvokeActionEvent{ @@ -281,6 +1080,7 @@ func TestServerInvokeAction(t *testing.T) { }, { Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{}, Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityWarning, @@ -297,6 +1097,286 @@ func TestServerInvokeAction(t *testing.T) { }, }, }, + "response-linkedresource-not-found": { + 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 = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_not_the_right_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_linked_resource\" linked resource data from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "The \"test_linked_resource\" linked resource was not found on the provider server.", + }, + }, + }, + }, + }, + }, + "response-raw-linkedresource-invalid-resource-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV5LinkedResource{ + TypeName: "test_invalid_linked_resource", + Schema: func() *tfprotov5.Schema { + return &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + // Tuple is not supported in framework + { + Name: "test_tuple", + Type: tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.Bool}}, + Required: true, + }, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_invalid_linked_resource\" linked resource schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "no supported attribute for \"test_tuple\", type: tftypes.Tuple", + }, + }, + }, + }, + }, + }, + "response-raw-linkedresource-invalid-identity-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV5LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov5.Schema { + return &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + IdentitySchema: func() *tfprotov5.ResourceIdentitySchema { + return &tfprotov5.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov5.ResourceIdentitySchemaAttribute{ + // Set is not a valid type for resource identity + { + Name: "test_id", + Type: tftypes.Set{ElementType: tftypes.Bool}, + RequiredForImport: true, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_linked_resource\" linked resource identity schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "no supported identity attribute for \"test_id\", type: tftypes.Set", + }, + }, + }, + }, + }, + }, + "response-raw-linkedresource-v6-resource-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV6LinkedResource{ + TypeName: "test_v6_linked_resource", + Schema: func() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_v6_linked_resource\" linked resource schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "The \"test_v6_linked_resource\" linked resource is a protocol v6 resource but the provider is being served using protocol v5.", + }, + }, + }, + }, + }, + }, } for name, testCase := range testCases { diff --git a/internal/toproto5/invoke_action_event.go b/internal/toproto5/invoke_action_event.go index d52e8a6eb..b6901e382 100644 --- a/internal/toproto5/invoke_action_event.go +++ b/internal/toproto5/invoke_action_event.go @@ -18,11 +18,28 @@ func ProgressInvokeActionEventType(ctx context.Context, event fwserver.InvokePro } } -func CompletedInvokeActionEventType(ctx context.Context, event *fwserver.InvokeActionResponse) tfprotov5.InvokeActionEvent { +func CompletedInvokeActionEventType(ctx context.Context, fw *fwserver.InvokeActionResponse) tfprotov5.InvokeActionEvent { + completedEvent := tfprotov5.CompletedInvokeActionEventType{ + Diagnostics: Diagnostics(ctx, fw.Diagnostics), + } + + completedEvent.LinkedResources = make([]*tfprotov5.NewLinkedResource, len(fw.LinkedResources)) + + for i, linkedResource := range fw.LinkedResources { + newState, diags := State(ctx, linkedResource.NewState) + completedEvent.Diagnostics = append(completedEvent.Diagnostics, Diagnostics(ctx, diags)...) + + newIdentity, diags := ResourceIdentity(ctx, linkedResource.NewIdentity) + completedEvent.Diagnostics = append(completedEvent.Diagnostics, Diagnostics(ctx, diags)...) + + completedEvent.LinkedResources[i] = &tfprotov5.NewLinkedResource{ + NewState: newState, + NewIdentity: newIdentity, + RequiresReplace: linkedResource.RequiresReplace, + } + } + return tfprotov5.InvokeActionEvent{ - Type: tfprotov5.CompletedInvokeActionEventType{ - // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented - Diagnostics: Diagnostics(ctx, event.Diagnostics), - }, + Type: completedEvent, } } diff --git a/internal/toproto5/invoke_action_event_test.go b/internal/toproto5/invoke_action_event_test.go index 7921e9b35..e2a9341ba 100644 --- a/internal/toproto5/invoke_action_event_test.go +++ b/internal/toproto5/invoke_action_event_test.go @@ -11,7 +11,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" ) func TestProgressInvokeActionEventType(t *testing.T) { @@ -49,10 +53,128 @@ func TestProgressInvokeActionEventType(t *testing.T) { func TestCompletedInvokeActionEventType(t *testing.T) { t.Parallel() + testLinkedResourceProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute_one": tftypes.String, + "test_attribute_two": tftypes.Bool, + }, + } + + testLinkedResourceProto5Value := tftypes.NewValue(testLinkedResourceProto5Type, map[string]tftypes.Value{ + "test_attribute_one": tftypes.NewValue(tftypes.String, "test-value-1"), + "test_attribute_two": tftypes.NewValue(tftypes.Bool, true), + }) + + testLinkedResourceProto5DynamicValue, err := tfprotov5.NewDynamicValue(testLinkedResourceProto5Type, testLinkedResourceProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_attribute_one": resourceschema.StringAttribute{ + Required: true, + }, + "test_attribute_two": resourceschema.BoolAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceIdentityProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testLinkedResourceIdentityProto5Value := tftypes.NewValue(testLinkedResourceIdentityProto5Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testLinkedResourceIdentityProto5DynamicValue, err := tfprotov5.NewDynamicValue(testLinkedResourceIdentityProto5Type, testLinkedResourceIdentityProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testCases := map[string]struct { fw *fwserver.InvokeActionResponse expected tfprotov5.InvokeActionEvent }{ + "linkedresource": { + fw: &fwserver.InvokeActionResponse{ + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{ + { + NewState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto5Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + expected: tfprotov5.InvokeActionEvent{ + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{ + { + NewState: &testLinkedResourceProto5DynamicValue, + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + }, + }, + }, + }, + "linkedresources": { + fw: &fwserver.InvokeActionResponse{ + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{ + { + NewState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto5Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + NewState: &tfsdk.State{ + Raw: testLinkedResourceProto5Value, + Schema: testLinkedResourceSchema, + }, + }, + }, + }, + expected: tfprotov5.InvokeActionEvent{ + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{ + { + NewState: &testLinkedResourceProto5DynamicValue, + NewIdentity: &tfprotov5.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto5DynamicValue, + }, + }, + { + NewState: &testLinkedResourceProto5DynamicValue, + }, + }, + }, + }, + }, "diagnostics": { fw: &fwserver.InvokeActionResponse{ Diagnostics: diag.Diagnostics{ @@ -62,6 +184,7 @@ func TestCompletedInvokeActionEventType(t *testing.T) { }, expected: tfprotov5.InvokeActionEvent{ Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{}, Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityWarning, From 27c515521a5278603595008665f439b69944bdd8 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 5 Aug 2025 17:42:45 -0400 Subject: [PATCH 14/18] refactor linked resource schema retrieval --- .../server_upgraderesourceidentity_test.go | 11 +-- internal/proto5server/server_invokeaction.go | 77 ++-------------- .../proto5server/server_linked_resources.go | 92 +++++++++++++++++++ internal/proto5server/server_planaction.go | 77 ++-------------- 4 files changed, 106 insertions(+), 151 deletions(-) create mode 100644 internal/proto5server/server_linked_resources.go diff --git a/internal/fwserver/server_upgraderesourceidentity_test.go b/internal/fwserver/server_upgraderesourceidentity_test.go index 48c15c85d..79cfc5676 100644 --- a/internal/fwserver/server_upgraderesourceidentity_test.go +++ b/internal/fwserver/server_upgraderesourceidentity_test.go @@ -7,9 +7,10 @@ import ( "context" "encoding/json" "fmt" - "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" "testing" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -213,14 +214,6 @@ func TestServerUpgradeResourceIdentity(t *testing.T) { Schema: testIdentitySchema, } - if err != nil { - resp.Diagnostics.AddError( - "Unable to Convert Upgraded Identity", - err.Error(), - ) - return - } - resp.Identity = ResourceIdentity }, }, diff --git a/internal/proto5server/server_invokeaction.go b/internal/proto5server/server_invokeaction.go index 74ce98c50..7d1f42773 100644 --- a/internal/proto5server/server_invokeaction.go +++ b/internal/proto5server/server_invokeaction.go @@ -5,12 +5,9 @@ package proto5server import ( "context" - "fmt" - "github.com/hashicorp/terraform-plugin-framework/action/schema" "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/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" @@ -55,74 +52,12 @@ func (s *Server) InvokeAction(ctx context.Context, proto5Req *tfprotov5.InvokeAc return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) } - lrSchemas := make([]fwschema.Schema, 0) - lrIdentitySchemas := make([]fwschema.Schema, 0) - for _, lrType := range actionSchema.LinkedResourceTypes() { - switch lrType := lrType.(type) { - case schema.RawV5LinkedResource: - // Raw linked resources are not stored on this provider server, so we retrieve the schemas from the - // action definition directly and convert them to framework schemas. - lrSchema, err := fromproto5.ResourceSchema(ctx, lrType.GetSchema()) - if err != nil { - fwResp.Diagnostics.AddError( - "Invalid Linked Resource Schema", - fmt.Sprintf("An unexpected error was encountered when converting %q linked resource schema from the protocol type. "+ - "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ - "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), - ) - - return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) - } - lrSchemas = append(lrSchemas, lrSchema) - - lrIdentitySchema, err := fromproto5.IdentitySchema(ctx, lrType.GetIdentitySchema()) - if err != nil { - fwResp.Diagnostics.AddError( - "Invalid Linked Resource Schema", - fmt.Sprintf("An unexpected error was encountered when converting %q linked resource identity schema from the protocol type. "+ - "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ - "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), - ) - - return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) - } - lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) - case schema.RawV6LinkedResource: - fwResp.Diagnostics.AddError( - "Invalid Linked Resource Schema", - fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource schema from the protocol type. "+ - "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ - "Please report this to the provider developer:\n\n"+ - "The %[1]q linked resource is a protocol v6 resource but the provider is being served using protocol v5.", lrType.GetTypeName()), - ) - - return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) - default: - // Any other linked resource type should be stored on the same provider server as the action, - // so we can just retrieve it via the type name. - lrSchema, diags := s.FrameworkServer.ResourceSchema(ctx, lrType.GetTypeName()) - if diags.HasError() { - fwResp.Diagnostics.AddError( - "Invalid Linked Resource Schema", - fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource data from the protocol type. "+ - "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ - "Please report this to the provider developer:\n\n"+ - "The %[1]q linked resource was not found on the provider server.", lrType.GetTypeName()), - ) - - return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) - } - lrSchemas = append(lrSchemas, lrSchema) - - lrIdentitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, lrType.GetTypeName()) - fwResp.Diagnostics.Append(diags...) - if fwResp.Diagnostics.HasError() { - // If the resource is found, the identity schema will only return a diagnostic if the provider implementation - // returns an error from (resource.Resource).IdentitySchema method. - return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) - } - lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) - } + lrSchemas, lrIdentitySchemas, diags := s.LinkedResourceSchemas(ctx, actionSchema) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) } fwReq, diags := fromproto5.InvokeActionRequest(ctx, proto5Req, action, actionSchema, lrSchemas, lrIdentitySchemas) diff --git a/internal/proto5server/server_linked_resources.go b/internal/proto5server/server_linked_resources.go new file mode 100644 index 000000000..13312f4c0 --- /dev/null +++ b/internal/proto5server/server_linked_resources.go @@ -0,0 +1,92 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto5server + +import ( + "context" + "fmt" + + actionschema "github.com/hashicorp/terraform-plugin-framework/action/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" +) + +// LinkedResourceSchemas returns the linked resource schemas for the given Action Schema. Linked resource schemas +// are either retrieved from the provider server or converted from the action schema definition. +func (s *Server) LinkedResourceSchemas(ctx context.Context, actionSchema actionschema.SchemaType) ([]fwschema.Schema, []fwschema.Schema, diag.Diagnostics) { + allDiags := make(diag.Diagnostics, 0) + lrSchemas := make([]fwschema.Schema, 0) + lrIdentitySchemas := make([]fwschema.Schema, 0) + + for _, lrType := range actionSchema.LinkedResourceTypes() { + switch lrType := lrType.(type) { + case actionschema.RawV5LinkedResource: + // Raw linked resources are not stored on this provider server, so we retrieve the schemas from the + // action definition directly and convert them to framework schemas. + lrSchema, err := fromproto5.ResourceSchema(ctx, lrType.GetSchema()) + if err != nil { + allDiags.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %q linked resource schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), + ) + + return nil, nil, allDiags + } + lrSchemas = append(lrSchemas, lrSchema) + + lrIdentitySchema, err := fromproto5.IdentitySchema(ctx, lrType.GetIdentitySchema()) + if err != nil { + allDiags.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %q linked resource identity schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), + ) + + return nil, nil, allDiags + } + lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) + case actionschema.RawV6LinkedResource: + allDiags.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "The %[1]q linked resource is a protocol v6 resource but the provider is being served using protocol v5.", lrType.GetTypeName()), + ) + + return nil, nil, allDiags + default: + // Any other linked resource type should be stored on the same provider server as the action, + // so we can just retrieve it via the type name. + lrSchema, diags := s.FrameworkServer.ResourceSchema(ctx, lrType.GetTypeName()) + if diags.HasError() { + allDiags.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource data from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "The %[1]q linked resource was not found on the provider server.", lrType.GetTypeName()), + ) + + return nil, nil, allDiags + } + lrSchemas = append(lrSchemas, lrSchema) + + lrIdentitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, lrType.GetTypeName()) + allDiags.Append(diags...) + if allDiags.HasError() { + // If the resource is found, the identity schema will only return a diagnostic if the provider implementation + // returns an error from (resource.Resource).IdentitySchema method. + return nil, nil, allDiags + } + lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) + } + } + + return lrSchemas, lrIdentitySchemas, allDiags +} diff --git a/internal/proto5server/server_planaction.go b/internal/proto5server/server_planaction.go index a41156fe6..fded848fc 100644 --- a/internal/proto5server/server_planaction.go +++ b/internal/proto5server/server_planaction.go @@ -5,11 +5,8 @@ package proto5server import ( "context" - "fmt" - "github.com/hashicorp/terraform-plugin-framework/action/schema" "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" - "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" @@ -39,74 +36,12 @@ func (s *Server) PlanAction(ctx context.Context, proto5Req *tfprotov5.PlanAction return toproto5.PlanActionResponse(ctx, fwResp), nil } - lrSchemas := make([]fwschema.Schema, 0) - lrIdentitySchemas := make([]fwschema.Schema, 0) - for _, lrType := range actionSchema.LinkedResourceTypes() { - switch lrType := lrType.(type) { - case schema.RawV5LinkedResource: - // Raw linked resources are not stored on this provider server, so we retrieve the schemas from the - // action definition directly and convert them to framework schemas. - lrSchema, err := fromproto5.ResourceSchema(ctx, lrType.GetSchema()) - if err != nil { - fwResp.Diagnostics.AddError( - "Invalid Linked Resource Schema", - fmt.Sprintf("An unexpected error was encountered when converting %q linked resource schema from the protocol type. "+ - "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ - "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), - ) - - return toproto5.PlanActionResponse(ctx, fwResp), nil //nolint:nilerr // error is assigned to fwResp.Diagnostics - } - lrSchemas = append(lrSchemas, lrSchema) - - lrIdentitySchema, err := fromproto5.IdentitySchema(ctx, lrType.GetIdentitySchema()) - if err != nil { - fwResp.Diagnostics.AddError( - "Invalid Linked Resource Schema", - fmt.Sprintf("An unexpected error was encountered when converting %q linked resource identity schema from the protocol type. "+ - "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ - "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), - ) - - return toproto5.PlanActionResponse(ctx, fwResp), nil //nolint:nilerr // error is assigned to fwResp.Diagnostics - } - lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) - case schema.RawV6LinkedResource: - fwResp.Diagnostics.AddError( - "Invalid Linked Resource Schema", - fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource schema from the protocol type. "+ - "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ - "Please report this to the provider developer:\n\n"+ - "The %[1]q linked resource is a protocol v6 resource but the provider is being served using protocol v5.", lrType.GetTypeName()), - ) - - return toproto5.PlanActionResponse(ctx, fwResp), nil //nolint:nilerr // error is assigned to fwResp.Diagnostics - default: - // Any other linked resource type should be stored on the same provider server as the action, - // so we can just retrieve it via the type name. - lrSchema, diags := s.FrameworkServer.ResourceSchema(ctx, lrType.GetTypeName()) - if diags.HasError() { - fwResp.Diagnostics.AddError( - "Invalid Linked Resource Schema", - fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource data from the protocol type. "+ - "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ - "Please report this to the provider developer:\n\n"+ - "The %[1]q linked resource was not found on the provider server.", lrType.GetTypeName()), - ) - - return toproto5.PlanActionResponse(ctx, fwResp), nil - } - lrSchemas = append(lrSchemas, lrSchema) - - lrIdentitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, lrType.GetTypeName()) - fwResp.Diagnostics.Append(diags...) - if fwResp.Diagnostics.HasError() { - // If the resource is found, the identity schema will only return a diagnostic if the provider implementation - // returns an error from (resource.Resource).IdentitySchema method. - return toproto5.PlanActionResponse(ctx, fwResp), nil - } - lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) - } + lrSchemas, lrIdentitySchemas, diags := s.LinkedResourceSchemas(ctx, actionSchema) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto5.PlanActionResponse(ctx, fwResp), nil } fwReq, diags := fromproto5.PlanActionRequest(ctx, proto5Req, action, actionSchema, lrSchemas, lrIdentitySchemas) From 5ea07eee2966d51c5132430ae0c55342d985817b Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 5 Aug 2025 18:59:52 -0400 Subject: [PATCH 15/18] fromproto --- internal/fromproto6/identity_schema.go | 79 ++ internal/fromproto6/identity_schema_test.go | 142 ++++ internal/fromproto6/invokeaction.go | 83 ++- internal/fromproto6/invokeaction_test.go | 315 +++++++- internal/fromproto6/planaction.go | 83 ++- internal/fromproto6/planaction_test.go | 322 +++++++- internal/fromproto6/resource_schema.go | 263 +++++++ internal/fromproto6/resource_schema_test.go | 781 ++++++++++++++++++++ 8 files changed, 2042 insertions(+), 26 deletions(-) create mode 100644 internal/fromproto6/identity_schema.go create mode 100644 internal/fromproto6/identity_schema_test.go create mode 100644 internal/fromproto6/resource_schema.go create mode 100644 internal/fromproto6/resource_schema_test.go diff --git a/internal/fromproto6/identity_schema.go b/internal/fromproto6/identity_schema.go new file mode 100644 index 000000000..be4dd0934 --- /dev/null +++ b/internal/fromproto6/identity_schema.go @@ -0,0 +1,79 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto6 + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// IdentitySchema converts a *tfprotov6.ResourceIdentitySchema into a resource/identityschema Schema, used for +// converting raw linked resource identity schemas (from another provider server, such as terraform-plugin-go) +// into Framework identity schemas. +func IdentitySchema(ctx context.Context, s *tfprotov6.ResourceIdentitySchema) (*identityschema.Schema, error) { + if s == nil { + return nil, nil + } + + attrs, err := IdentitySchemaAttributes(ctx, s.IdentityAttributes) + if err != nil { + return nil, err + } + + return &identityschema.Schema{ + // MAINTAINER NOTE: At the moment, there isn't a need to copy all of the data from the protocol identity schema + // to the resource identity schema, just enough data to allow provider developers to read and set data. + Attributes: attrs, + }, nil +} + +func IdentitySchemaAttributes(ctx context.Context, protoAttrs []*tfprotov6.ResourceIdentitySchemaAttribute) (map[string]identityschema.Attribute, error) { + attrs := make(map[string]identityschema.Attribute, len(protoAttrs)) + for _, protoAttr := range protoAttrs { + // MAINTAINER NOTE: At the moment, there isn't a need to copy all of the data from the protocol identity schema + // to the resource identity schema, just enough data to allow provider developers to read and set data. + switch { + case protoAttr.Type.Is(tftypes.Bool): + attrs[protoAttr.Name] = identityschema.BoolAttribute{ + RequiredForImport: protoAttr.RequiredForImport, + OptionalForImport: protoAttr.OptionalForImport, + } + case protoAttr.Type.Is(tftypes.Number): + attrs[protoAttr.Name] = identityschema.NumberAttribute{ + RequiredForImport: protoAttr.RequiredForImport, + OptionalForImport: protoAttr.OptionalForImport, + } + case protoAttr.Type.Is(tftypes.String): + attrs[protoAttr.Name] = identityschema.StringAttribute{ + RequiredForImport: protoAttr.RequiredForImport, + OptionalForImport: protoAttr.OptionalForImport, + } + case protoAttr.Type.Is(tftypes.List{}): + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + l := protoAttr.Type.(tftypes.List) + + elementType, err := basetypes.TerraformTypeToFrameworkType(l.ElementType) + if err != nil { + return nil, err + } + + attrs[protoAttr.Name] = identityschema.ListAttribute{ + ElementType: elementType, + RequiredForImport: protoAttr.RequiredForImport, + OptionalForImport: protoAttr.OptionalForImport, + } + default: + // MAINTAINER NOTE: Not all terraform types are valid identity attribute types. Framework fully supports + // all of the possible identity attribute types, so any errors here would be invalid protocol identities. + return nil, fmt.Errorf("no supported identity attribute for %q, type: %T", protoAttr.Name, protoAttr.Type) + } + } + + return attrs, nil +} diff --git a/internal/fromproto6/identity_schema_test.go b/internal/fromproto6/identity_schema_test.go new file mode 100644 index 000000000..18b1b718e --- /dev/null +++ b/internal/fromproto6/identity_schema_test.go @@ -0,0 +1,142 @@ +// 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/internal/fromproto6" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestIdentitySchema(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input *tfprotov6.ResourceIdentitySchema + expected *identityschema.Schema + expectedErr string + }{ + "nil": { + input: nil, + expected: nil, + }, + "no-attrs": { + input: &tfprotov6.ResourceIdentitySchema{}, + expected: &identityschema.Schema{ + Attributes: make(map[string]identityschema.Attribute, 0), + }, + }, + "primitives-attrs": { + input: &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + RequiredForImport: true, + }, + { + Name: "number", + Type: tftypes.Number, + OptionalForImport: true, + }, + { + Name: "string", + Type: tftypes.String, + OptionalForImport: true, + }, + }, + }, + expected: &identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "bool": identityschema.BoolAttribute{ + RequiredForImport: true, + }, + "number": identityschema.NumberAttribute{ + OptionalForImport: true, + }, + "string": identityschema.StringAttribute{ + OptionalForImport: true, + }, + }, + }, + }, + "list-attr": { + input: &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "list_of_bools", + Type: tftypes.List{ElementType: tftypes.Bool}, + RequiredForImport: true, + }, + }, + }, + expected: &identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "list_of_bools": identityschema.ListAttribute{ + ElementType: basetypes.BoolType{}, + RequiredForImport: true, + }, + }, + }, + }, + "map-error": { + input: &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "map_of_strings", + Type: tftypes.Map{ElementType: tftypes.String}, + OptionalForImport: true, + }, + }, + }, + expectedErr: `no supported identity attribute for "map_of_strings", type: tftypes.Map`, + }, + "set-error": { + input: &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "set_of_strings", + Type: tftypes.Set{ElementType: tftypes.String}, + OptionalForImport: true, + }, + }, + }, + expectedErr: `no supported identity attribute for "set_of_strings", type: tftypes.Set`, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := fromproto6.IdentitySchema(context.Background(), tc.input) + if err != nil { + if tc.expectedErr == "" { + t.Errorf("Unexpected error: %s", err) + return + } + if err.Error() != tc.expectedErr { + t.Errorf("Expected error to be %q, got %q", tc.expectedErr, err.Error()) + return + } + // got expected error + return + } + if tc.expectedErr != "" { + t.Errorf("Expected error to be %q, got nil", tc.expectedErr) + return + } + if diff := cmp.Diff(got, tc.expected); diff != "" { + t.Errorf("Unexpected diff (+wanted, -got): %s", diff) + return + } + }) + } +} diff --git a/internal/fromproto6/invokeaction.go b/internal/fromproto6/invokeaction.go index 04ca704b4..148365fb4 100644 --- a/internal/fromproto6/invokeaction.go +++ b/internal/fromproto6/invokeaction.go @@ -5,6 +5,7 @@ package fromproto6 import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-go/tfprotov6" @@ -12,10 +13,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) // InvokeActionRequest returns the *fwserver.InvokeActionRequest equivalent of a *tfprotov6.InvokeActionRequest. -func InvokeActionRequest(ctx context.Context, proto6 *tfprotov6.InvokeActionRequest, reqAction action.Action, actionSchema fwschema.Schema) (*fwserver.InvokeActionRequest, diag.Diagnostics) { +func InvokeActionRequest(ctx context.Context, proto6 *tfprotov6.InvokeActionRequest, reqAction action.Action, actionSchema fwschema.Schema, linkedResourceSchemas []fwschema.Schema, linkedResourceIdentitySchemas []fwschema.Schema) (*fwserver.InvokeActionRequest, diag.Diagnostics) { if proto6 == nil { return nil, nil } @@ -47,7 +49,84 @@ func InvokeActionRequest(ctx context.Context, proto6 *tfprotov6.InvokeActionRequ fw.Config = config - // TODO:Actions: Here we need to retrieve linked resource data + if len(proto6.LinkedResources) != len(linkedResourceSchemas) { + diags.AddError( + "Mismatched Linked Resources in InvokeAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + fmt.Sprintf( + "Received %d linked resource(s), but the provider was expecting %d linked resource(s).", + len(proto6.LinkedResources), + len(linkedResourceSchemas), + ), + ) + + return nil, diags + } + + // MAINTAINER NOTE: The number of identity schemas should always be in sync (if not supported, will have nil), + // so this error check is more for panic prevention. + if len(proto6.LinkedResources) != len(linkedResourceIdentitySchemas) { + diags.AddError( + "Mismatched Linked Resources in InvokeAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + fmt.Sprintf( + "Received %d linked resource(s), but the provider was expecting %d linked resource(s).", + len(proto6.LinkedResources), + len(linkedResourceIdentitySchemas), + ), + ) + + return nil, diags + } + + for i, linkedResource := range proto6.LinkedResources { + schema := linkedResourceSchemas[i] + identitySchema := linkedResourceIdentitySchemas[i] + + // Config + config, configDiags := Config(ctx, linkedResource.Config, schema) + diags.Append(configDiags...) + + // Prior state + priorState, priorStateDiags := State(ctx, linkedResource.PriorState, schema) + diags.Append(priorStateDiags...) + + // Planned state (plan) + plannedState, plannedStateDiags := Plan(ctx, linkedResource.PlannedState, schema) + diags.Append(plannedStateDiags...) + + // Planned identity + var plannedIdentity *tfsdk.ResourceIdentity + if linkedResource.PlannedIdentity != nil { + if identitySchema == nil { + // MAINTAINER NOTE: Not all linked resources support identity, so it's valid for an identity schema to be nil. However, + // it's not valid for Terraform core to send an identity for a linked resource that doesn't support one. This would likely indicate + // that there is a bug in the definition of the linked resources (not including an identity schema when it is supported), or a bug in + // either Terraform core/Framework. + diags.AddError( + "Unable to Convert Linked Resource Identity", + "An unexpected error was encountered when converting a linked resource identity from the protocol type. "+ + fmt.Sprintf("Linked resource (at index %d) contained identity data, but the resource doesn't support identity.\n\n", i)+ + "This is always a problem with the provider and should be reported to the provider developer.", + ) + return nil, diags + } + + identityVal, plannedIdentityDiags := ResourceIdentity(ctx, linkedResource.PlannedIdentity, identitySchema) + diags.Append(plannedIdentityDiags...) + + plannedIdentity = identityVal + } + + fw.LinkedResources = append(fw.LinkedResources, &fwserver.InvokeActionRequestLinkedResource{ + Config: config, + PlannedState: plannedState, + PriorState: priorState, + PlannedIdentity: plannedIdentity, + }) + } return fw, diags } diff --git a/internal/fromproto6/invokeaction_test.go b/internal/fromproto6/invokeaction_test.go index 9772d420e..6afe633b6 100644 --- a/internal/fromproto6/invokeaction_test.go +++ b/internal/fromproto6/invokeaction_test.go @@ -12,17 +12,27 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-framework/action" - "github.com/hashicorp/terraform-plugin-framework/action/schema" + actionschema "github.com/hashicorp/terraform-plugin-framework/action/schema" "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/fwserver" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) func TestInvokeActionRequest(t *testing.T) { t.Parallel() + testEmptyProto6Value := tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}, map[string]tftypes.Value{}) + + testEmptyProto6DynamicValue, err := tfprotov6.NewDynamicValue(tftypes.Object{}, testEmptyProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + testProto6Type := tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "test_attribute": tftypes.String, @@ -39,21 +49,83 @@ func TestInvokeActionRequest(t *testing.T) { t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) } - testUnlinkedSchema := schema.UnlinkedSchema{ - Attributes: map[string]schema.Attribute{ - "test_attribute": schema.StringAttribute{ + testLinkedResourceProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute_one": tftypes.String, + "test_attribute_two": tftypes.Bool, + }, + } + + testLinkedResourceProto6Value := tftypes.NewValue(testLinkedResourceProto6Type, map[string]tftypes.Value{ + "test_attribute_one": tftypes.NewValue(tftypes.String, "test-value-1"), + "test_attribute_two": tftypes.NewValue(tftypes.Bool, true), + }) + + testLinkedResourceProto6DynamicValue, err := tfprotov6.NewDynamicValue(testLinkedResourceProto6Type, testLinkedResourceProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_attribute_one": resourceschema.StringAttribute{ + Required: true, + }, + "test_attribute_two": resourceschema.BoolAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceIdentityProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testLinkedResourceIdentityProto6Value := tftypes.NewValue(testLinkedResourceIdentityProto6Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testLinkedResourceIdentityProto6DynamicValue, err := tfprotov6.NewDynamicValue(testLinkedResourceIdentityProto6Type, testLinkedResourceIdentityProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testUnlinkedSchema := actionschema.UnlinkedSchema{ + Attributes: map[string]actionschema.Attribute{ + "test_attribute": actionschema.StringAttribute{ Required: true, }, }, } + testLifecycleSchemaLinked := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.LinkedResource{ + TypeName: "test_linked_resource", + }, + } + testCases := map[string]struct { - input *tfprotov6.InvokeActionRequest - actionSchema fwschema.Schema - actionImpl action.Action - providerMetaSchema fwschema.Schema - expected *fwserver.InvokeActionRequest - expectedDiagnostics diag.Diagnostics + input *tfprotov6.InvokeActionRequest + actionSchema fwschema.Schema + actionImpl action.Action + linkedResourceSchemas []fwschema.Schema + linkedResourceIdentitySchemas []fwschema.Schema + providerMetaSchema fwschema.Schema + expected *fwserver.InvokeActionRequest + expectedDiagnostics diag.Diagnostics }{ "nil": { input: nil, @@ -100,13 +172,234 @@ func TestInvokeActionRequest(t *testing.T) { ActionSchema: testUnlinkedSchema, }, }, + "linkedresource": { + input: &tfprotov6.InvokeActionRequest{ + Config: &testEmptyProto6DynamicValue, + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + }, + actionSchema: testLifecycleSchemaLinked, + expected: &fwserver.InvokeActionRequest{ + ActionSchema: testLifecycleSchemaLinked, + Config: &tfsdk.Config{ + Raw: testEmptyProto6Value, + Schema: testLifecycleSchemaLinked, + }, + LinkedResources: []*fwserver.InvokeActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto6Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + }, + "linkedresources": { + input: &tfprotov6.InvokeActionRequest{ + Config: &testEmptyProto6DynamicValue, + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + nil, // Second resource doesn't have an identity + }, + actionSchema: testLifecycleSchemaLinked, + expected: &fwserver.InvokeActionRequest{ + ActionSchema: testLifecycleSchemaLinked, + Config: &tfsdk.Config{ + Raw: testEmptyProto6Value, + Schema: testLifecycleSchemaLinked, + }, + LinkedResources: []*fwserver.InvokeActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto6Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + }, + }, + }, + }, + "linkedresources-mismatched-number-of-schemas": { + input: &tfprotov6.InvokeActionRequest{ + Config: &testEmptyProto6DynamicValue, + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + nil, // Second resource doesn't have an identity + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Mismatched Linked Resources in InvokeAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + "Received 2 linked resource(s), but the provider was expecting 1 linked resource(s).", + ), + }, + }, + "linkedresources-mismatched-number-of-identity-schemas": { + input: &tfprotov6.InvokeActionRequest{ + Config: &testEmptyProto6DynamicValue, + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Mismatched Linked Resources in InvokeAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + "Received 2 linked resource(s), but the provider was expecting 1 linked resource(s).", + ), + }, + }, + "linkedresources-no-identity-schema": { + input: &tfprotov6.InvokeActionRequest{ + Config: &testEmptyProto6DynamicValue, + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + nil, + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Linked Resource Identity", + "An unexpected error was encountered when converting a linked resource identity from the protocol type. "+ + "Linked resource (at index 0) contained identity data, but the resource doesn't support identity.\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.", + ), + }, + }, } for name, testCase := range testCases { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto6.InvokeActionRequest(context.Background(), testCase.input, testCase.actionImpl, testCase.actionSchema) + got, diags := fromproto6.InvokeActionRequest(context.Background(), testCase.input, testCase.actionImpl, testCase.actionSchema, testCase.linkedResourceSchemas, testCase.linkedResourceIdentitySchemas) if diff := cmp.Diff(got, testCase.expected); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fromproto6/planaction.go b/internal/fromproto6/planaction.go index 838698a87..fa5267254 100644 --- a/internal/fromproto6/planaction.go +++ b/internal/fromproto6/planaction.go @@ -5,6 +5,7 @@ package fromproto6 import ( "context" + "fmt" "github.com/hashicorp/terraform-plugin-go/tfprotov6" @@ -12,10 +13,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) // PlanActionRequest returns the *fwserver.PlanActionRequest equivalent of a *tfprotov6.PlanActionRequest. -func PlanActionRequest(ctx context.Context, proto6 *tfprotov6.PlanActionRequest, reqAction action.Action, actionSchema fwschema.Schema) (*fwserver.PlanActionRequest, diag.Diagnostics) { +func PlanActionRequest(ctx context.Context, proto6 *tfprotov6.PlanActionRequest, reqAction action.Action, actionSchema fwschema.Schema, linkedResourceSchemas []fwschema.Schema, linkedResourceIdentitySchemas []fwschema.Schema) (*fwserver.PlanActionRequest, diag.Diagnostics) { if proto6 == nil { return nil, nil } @@ -48,7 +50,84 @@ func PlanActionRequest(ctx context.Context, proto6 *tfprotov6.PlanActionRequest, fw.Config = config - // TODO:Actions: Here we need to retrieve linked resource data + if len(proto6.LinkedResources) != len(linkedResourceSchemas) { + diags.AddError( + "Mismatched Linked Resources in PlanAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + fmt.Sprintf( + "Received %d linked resource(s), but the provider was expecting %d linked resource(s).", + len(proto6.LinkedResources), + len(linkedResourceSchemas), + ), + ) + + return nil, diags + } + + // MAINTAINER NOTE: The number of identity schemas should always be in sync (if not supported, will have nil), + // so this error check is more for panic prevention. + if len(proto6.LinkedResources) != len(linkedResourceIdentitySchemas) { + diags.AddError( + "Mismatched Linked Resources in PlanAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + fmt.Sprintf( + "Received %d linked resource(s), but the provider was expecting %d linked resource(s).", + len(proto6.LinkedResources), + len(linkedResourceIdentitySchemas), + ), + ) + + return nil, diags + } + + for i, linkedResource := range proto6.LinkedResources { + schema := linkedResourceSchemas[i] + identitySchema := linkedResourceIdentitySchemas[i] + + // Config + config, configDiags := Config(ctx, linkedResource.Config, schema) + diags.Append(configDiags...) + + // Prior state + priorState, priorStateDiags := State(ctx, linkedResource.PriorState, schema) + diags.Append(priorStateDiags...) + + // Planned state (plan) + plannedState, plannedStateDiags := Plan(ctx, linkedResource.PlannedState, schema) + diags.Append(plannedStateDiags...) + + // Prior identity + var priorIdentity *tfsdk.ResourceIdentity + if linkedResource.PriorIdentity != nil { + if identitySchema == nil { + // MAINTAINER NOTE: Not all linked resources support identity, so it's valid for an identity schema to be nil. However, + // it's not valid for Terraform core to send an identity for a linked resource that doesn't support one. This would likely indicate + // that there is a bug in the definition of the linked resources (not including an identity schema when it is supported), or a bug in + // either Terraform core/Framework. + diags.AddError( + "Unable to Convert Linked Resource Identity", + "An unexpected error was encountered when converting a linked resource identity from the protocol type. "+ + fmt.Sprintf("Linked resource (at index %d) contained identity data, but the resource doesn't support identity.\n\n", i)+ + "This is always a problem with the provider and should be reported to the provider developer.", + ) + return nil, diags + } + + identityVal, priorIdentityDiags := ResourceIdentity(ctx, linkedResource.PriorIdentity, identitySchema) + diags.Append(priorIdentityDiags...) + + priorIdentity = identityVal + } + + fw.LinkedResources = append(fw.LinkedResources, &fwserver.PlanActionRequestLinkedResource{ + Config: config, + PlannedState: plannedState, + PriorState: priorState, + PriorIdentity: priorIdentity, + }) + } return fw, diags } diff --git a/internal/fromproto6/planaction_test.go b/internal/fromproto6/planaction_test.go index 6bc0fa7fe..3ae05cd76 100644 --- a/internal/fromproto6/planaction_test.go +++ b/internal/fromproto6/planaction_test.go @@ -12,17 +12,27 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-framework/action" - "github.com/hashicorp/terraform-plugin-framework/action/schema" + actionschema "github.com/hashicorp/terraform-plugin-framework/action/schema" "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/fwserver" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) func TestPlanActionRequest(t *testing.T) { t.Parallel() + testEmptyProto6Value := tftypes.NewValue(tftypes.Object{AttributeTypes: map[string]tftypes.Type{}}, map[string]tftypes.Value{}) + + testEmptyProto6DynamicValue, err := tfprotov6.NewDynamicValue(tftypes.Object{}, testEmptyProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + testProto6Type := tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "test_attribute": tftypes.String, @@ -39,21 +49,83 @@ func TestPlanActionRequest(t *testing.T) { t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) } - testUnlinkedSchema := schema.UnlinkedSchema{ - Attributes: map[string]schema.Attribute{ - "test_attribute": schema.StringAttribute{ + testLinkedResourceProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute_one": tftypes.String, + "test_attribute_two": tftypes.Bool, + }, + } + + testLinkedResourceProto6Value := tftypes.NewValue(testLinkedResourceProto6Type, map[string]tftypes.Value{ + "test_attribute_one": tftypes.NewValue(tftypes.String, "test-value-1"), + "test_attribute_two": tftypes.NewValue(tftypes.Bool, true), + }) + + testLinkedResourceProto6DynamicValue, err := tfprotov6.NewDynamicValue(testLinkedResourceProto6Type, testLinkedResourceProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_attribute_one": resourceschema.StringAttribute{ + Required: true, + }, + "test_attribute_two": resourceschema.BoolAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceIdentityProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testLinkedResourceIdentityProto6Value := tftypes.NewValue(testLinkedResourceIdentityProto6Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testLinkedResourceIdentityProto6DynamicValue, err := tfprotov6.NewDynamicValue(testLinkedResourceIdentityProto6Type, testLinkedResourceIdentityProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testUnlinkedSchema := actionschema.UnlinkedSchema{ + Attributes: map[string]actionschema.Attribute{ + "test_attribute": actionschema.StringAttribute{ Required: true, }, }, } + testLifecycleSchemaLinked := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.LinkedResource{ + TypeName: "test_linked_resource", + }, + } + testCases := map[string]struct { - input *tfprotov6.PlanActionRequest - actionSchema fwschema.Schema - actionImpl action.Action - providerMetaSchema fwschema.Schema - expected *fwserver.PlanActionRequest - expectedDiagnostics diag.Diagnostics + input *tfprotov6.PlanActionRequest + actionSchema fwschema.Schema + actionImpl action.Action + linkedResourceSchemas []fwschema.Schema + linkedResourceIdentitySchemas []fwschema.Schema + providerMetaSchema fwschema.Schema + expected *fwserver.PlanActionRequest + expectedDiagnostics diag.Diagnostics }{ "nil": { input: nil, @@ -124,13 +196,241 @@ func TestPlanActionRequest(t *testing.T) { }, }, }, + "linkedresource": { + input: &tfprotov6.PlanActionRequest{ + Config: &testEmptyProto6DynamicValue, + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + PriorIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + }, + actionSchema: testLifecycleSchemaLinked, + expected: &fwserver.PlanActionRequest{ + ActionSchema: testLifecycleSchemaLinked, + Config: &tfsdk.Config{ + Raw: testEmptyProto6Value, + Schema: testLifecycleSchemaLinked, + }, + LinkedResources: []*fwserver.PlanActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto6Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + }, + "linkedresources": { + input: &tfprotov6.PlanActionRequest{ + Config: &testEmptyProto6DynamicValue, + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + PriorIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + nil, // Second resource doesn't have an identity + }, + actionSchema: testLifecycleSchemaLinked, + expected: &fwserver.PlanActionRequest{ + ActionSchema: testLifecycleSchemaLinked, + Config: &tfsdk.Config{ + Raw: testEmptyProto6Value, + Schema: testLifecycleSchemaLinked, + }, + LinkedResources: []*fwserver.PlanActionRequestLinkedResource{ + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PriorIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto6Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + Config: &tfsdk.Config{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PlannedState: &tfsdk.Plan{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PriorState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + }, + }, + }, + }, + "linkedresources-mismatched-number-of-schemas": { + input: &tfprotov6.PlanActionRequest{ + Config: &testEmptyProto6DynamicValue, + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + PriorIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + nil, // Second resource doesn't have an identity + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Mismatched Linked Resources in PlanAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + "Received 2 linked resource(s), but the provider was expecting 1 linked resource(s).", + ), + }, + }, + "linkedresources-mismatched-number-of-identity-schemas": { + input: &tfprotov6.PlanActionRequest{ + Config: &testEmptyProto6DynamicValue, + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + PriorIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + testLinkedResourceIdentitySchema, + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Mismatched Linked Resources in PlanAction Request", + "An unexpected error was encountered when handling the request. "+ + "This is always a problem with Terraform or terraform-plugin-framework. Please report this to the provider developer.\n\n"+ + "Received 2 linked resource(s), but the provider was expecting 1 linked resource(s).", + ), + }, + }, + "linkedresources-no-identity-schema": { + input: &tfprotov6.PlanActionRequest{ + Config: &testEmptyProto6DynamicValue, + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: &testLinkedResourceProto6DynamicValue, + PlannedState: &testLinkedResourceProto6DynamicValue, + Config: &testLinkedResourceProto6DynamicValue, + PriorIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + }, + }, + linkedResourceSchemas: []fwschema.Schema{ + testLinkedResourceSchema, + }, + linkedResourceIdentitySchemas: []fwschema.Schema{ + nil, + }, + actionSchema: testLifecycleSchemaLinked, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Unable to Convert Linked Resource Identity", + "An unexpected error was encountered when converting a linked resource identity from the protocol type. "+ + "Linked resource (at index 0) contained identity data, but the resource doesn't support identity.\n\n"+ + "This is always a problem with the provider and should be reported to the provider developer.", + ), + }, + }, } for name, testCase := range testCases { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto6.PlanActionRequest(context.Background(), testCase.input, testCase.actionImpl, testCase.actionSchema) + got, diags := fromproto6.PlanActionRequest( + context.Background(), + testCase.input, + testCase.actionImpl, + testCase.actionSchema, + testCase.linkedResourceSchemas, + testCase.linkedResourceIdentitySchemas, + ) if diff := cmp.Diff(got, testCase.expected); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fromproto6/resource_schema.go b/internal/fromproto6/resource_schema.go new file mode 100644 index 000000000..7651c827b --- /dev/null +++ b/internal/fromproto6/resource_schema.go @@ -0,0 +1,263 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fromproto6 + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// ResourceSchema converts a *tfprotov6.Schema into a resource/schema Schema, used for +// converting raw linked resource schemas (from another provider server, such as terraform-plugin-go) +// into Framework schemas. +func ResourceSchema(ctx context.Context, s *tfprotov6.Schema) (*resourceschema.Schema, error) { + if s == nil || s.Block == nil { + return nil, nil + } + + attrs, err := ResourceSchemaAttributes(ctx, s.Block.Attributes) + if err != nil { + return nil, err + } + + blocks, err := ResourceSchemaNestedBlocks(ctx, s.Block.BlockTypes) + if err != nil { + return nil, err + } + + return &resourceschema.Schema{ + // MAINTAINER NOTE: At the moment, there isn't a need to copy all of the data from the protocol schema + // to the resource schema, just enough data to allow provider developers to read and set data. + Attributes: attrs, + Blocks: blocks, + }, nil +} + +func ResourceSchemaAttributes(ctx context.Context, protoAttrs []*tfprotov6.SchemaAttribute) (map[string]resourceschema.Attribute, error) { + attrs := make(map[string]resourceschema.Attribute, len(protoAttrs)) + for _, protoAttr := range protoAttrs { + if protoAttr.NestedType != nil { + nestedAttrs, err := ResourceSchemaAttributes(ctx, protoAttr.NestedType.Attributes) + if err != nil { + return nil, err + } + + switch protoAttr.NestedType.Nesting { + case tfprotov6.SchemaObjectNestingModeSingle: + attrs[protoAttr.Name] = resourceschema.SingleNestedAttribute{ + Attributes: nestedAttrs, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case tfprotov6.SchemaObjectNestingModeList: + attrs[protoAttr.Name] = resourceschema.ListNestedAttribute{ + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: nestedAttrs, + }, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case tfprotov6.SchemaObjectNestingModeSet: + attrs[protoAttr.Name] = resourceschema.SetNestedAttribute{ + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: nestedAttrs, + }, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + Sensitive: protoAttr.Sensitive, + } + case tfprotov6.SchemaObjectNestingModeMap: + attrs[protoAttr.Name] = resourceschema.MapNestedAttribute{ + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: nestedAttrs, + }, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + default: + return nil, fmt.Errorf("no supported nested attribute for %q, nesting mode: %s", protoAttr.Name, protoAttr.NestedType.Nesting) + } + + continue + } + + // MAINTAINER NOTE: At the moment, there isn't a need to copy all of the data from the protocol schema + // to the resource schema, just enough data to allow provider developers to read and set data. + switch { + case protoAttr.Type.Is(tftypes.Bool): + attrs[protoAttr.Name] = resourceschema.BoolAttribute{ + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.Number): + attrs[protoAttr.Name] = resourceschema.NumberAttribute{ + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.String): + attrs[protoAttr.Name] = resourceschema.StringAttribute{ + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.DynamicPseudoType): + attrs[protoAttr.Name] = resourceschema.DynamicAttribute{ + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.List{}): + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + l := protoAttr.Type.(tftypes.List) + + elementType, err := basetypes.TerraformTypeToFrameworkType(l.ElementType) + if err != nil { + return nil, err + } + + attrs[protoAttr.Name] = resourceschema.ListAttribute{ + ElementType: elementType, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.Map{}): + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + m := protoAttr.Type.(tftypes.Map) + + elementType, err := basetypes.TerraformTypeToFrameworkType(m.ElementType) + if err != nil { + return nil, err + } + + attrs[protoAttr.Name] = resourceschema.MapAttribute{ + ElementType: elementType, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.Set{}): + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + s := protoAttr.Type.(tftypes.Set) + + elementType, err := basetypes.TerraformTypeToFrameworkType(s.ElementType) + if err != nil { + return nil, err + } + + attrs[protoAttr.Name] = resourceschema.SetAttribute{ + ElementType: elementType, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + Sensitive: protoAttr.Sensitive, + } + case protoAttr.Type.Is(tftypes.Object{}): + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + o := protoAttr.Type.(tftypes.Object) + + attrTypes := make(map[string]attr.Type, len(o.AttributeTypes)) + for name, tfType := range o.AttributeTypes { + t, err := basetypes.TerraformTypeToFrameworkType(tfType) + if err != nil { + return nil, err + } + attrTypes[name] = t + } + + attrs[protoAttr.Name] = resourceschema.ObjectAttribute{ + AttributeTypes: attrTypes, + Required: protoAttr.Required, + Optional: protoAttr.Optional, + Computed: protoAttr.Computed, + WriteOnly: protoAttr.WriteOnly, + Sensitive: protoAttr.Sensitive, + } + default: + // MAINTAINER NOTE: Currently the only type not supported by Framework is a tuple, since there + // is no corresponding attribute to represent it. + // + // https://github.com/hashicorp/terraform-plugin-framework/issues/54 + return nil, fmt.Errorf("no supported attribute for %q, type: %T", protoAttr.Name, protoAttr.Type) + } + } + + return attrs, nil +} + +func ResourceSchemaNestedBlocks(ctx context.Context, protoBlocks []*tfprotov6.SchemaNestedBlock) (map[string]resourceschema.Block, error) { + nestedBlocks := make(map[string]resourceschema.Block, len(protoBlocks)) + for _, protoBlock := range protoBlocks { + if protoBlock.Block == nil { + continue + } + + attrs, err := ResourceSchemaAttributes(ctx, protoBlock.Block.Attributes) + if err != nil { + return nil, err + } + blocks, err := ResourceSchemaNestedBlocks(ctx, protoBlock.Block.BlockTypes) + if err != nil { + return nil, err + } + + switch protoBlock.Nesting { + case tfprotov6.SchemaNestedBlockNestingModeList: + nestedBlocks[protoBlock.TypeName] = resourceschema.ListNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: attrs, + Blocks: blocks, + }, + } + case tfprotov6.SchemaNestedBlockNestingModeSet: + nestedBlocks[protoBlock.TypeName] = resourceschema.SetNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: attrs, + Blocks: blocks, + }, + } + case tfprotov6.SchemaNestedBlockNestingModeSingle: + nestedBlocks[protoBlock.TypeName] = resourceschema.SingleNestedBlock{ + Attributes: attrs, + Blocks: blocks, + } + default: + // MAINTAINER NOTE: Currently the only block type not supported by Framework is a map nested block, since there + // is no corresponding framework block implementation to represent it. + return nil, fmt.Errorf("no supported block for nesting mode %v in nested block %q", protoBlock.Nesting, protoBlock.TypeName) + } + } + + return nestedBlocks, nil +} diff --git a/internal/fromproto6/resource_schema_test.go b/internal/fromproto6/resource_schema_test.go new file mode 100644 index 000000000..10d701d01 --- /dev/null +++ b/internal/fromproto6/resource_schema_test.go @@ -0,0 +1,781 @@ +// 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/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto6" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestResourceSchema(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input *tfprotov6.Schema + expected *resourceschema.Schema + expectedErr string + }{ + "nil": { + input: nil, + expected: nil, + }, + "no-block": { + input: &tfprotov6.Schema{}, + expected: nil, + }, + "no-attrs-no-nested-blocks": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{}, + }, + expected: &resourceschema.Schema{ + Attributes: make(map[string]resourceschema.Attribute, 0), + Blocks: make(map[string]resourceschema.Block, 0), + }, + }, + "primitives-attrs": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + { + Name: "number", + Type: tftypes.Number, + Optional: true, + Computed: true, + }, + { + Name: "string", + Type: tftypes.String, + Optional: true, + Computed: true, + Sensitive: true, + }, + { + Name: "dynamic", + Type: tftypes.DynamicPseudoType, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Required: true, + }, + "number": resourceschema.NumberAttribute{ + Optional: true, + Computed: true, + }, + "string": resourceschema.StringAttribute{ + Optional: true, + Computed: true, + Sensitive: true, + }, + "dynamic": resourceschema.DynamicAttribute{ + Optional: true, + WriteOnly: true, + }, + }, + Blocks: make(map[string]resourceschema.Block, 0), + }, + }, + "collection-attrs": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "list_of_bools", + Type: tftypes.List{ElementType: tftypes.Bool}, + Required: true, + WriteOnly: true, + }, + { + Name: "map_of_numbers", + Type: tftypes.Map{ElementType: tftypes.Number}, + Optional: true, + Computed: true, + }, + { + Name: "set_of_strings", + Type: tftypes.Set{ElementType: tftypes.String}, + Optional: true, + Computed: true, + Sensitive: true, + }, + { + Name: "list_of_objects", + Type: tftypes.List{ + ElementType: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic": tftypes.DynamicPseudoType, + "string": tftypes.String, + }, + }, + }, + Required: true, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "list_of_bools": resourceschema.ListAttribute{ + ElementType: basetypes.BoolType{}, + Required: true, + WriteOnly: true, + }, + "map_of_numbers": resourceschema.MapAttribute{ + ElementType: basetypes.NumberType{}, + Optional: true, + Computed: true, + }, + "set_of_strings": resourceschema.SetAttribute{ + ElementType: basetypes.StringType{}, + Optional: true, + Computed: true, + Sensitive: true, + }, + "list_of_objects": resourceschema.ListAttribute{ + ElementType: basetypes.ObjectType{ + AttrTypes: map[string]attr.Type{ + "dynamic": basetypes.DynamicType{}, + "string": basetypes.StringType{}, + }, + }, + Required: true, + }, + }, + Blocks: make(map[string]resourceschema.Block, 0), + }, + }, + "object-attr": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "object", + Type: tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "bool": tftypes.Bool, + "dynamic": tftypes.DynamicPseudoType, + "string": tftypes.String, + }, + }, + Optional: true, + Computed: true, + Sensitive: true, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "object": resourceschema.ObjectAttribute{ + AttributeTypes: map[string]attr.Type{ + "bool": basetypes.BoolType{}, + "dynamic": basetypes.DynamicType{}, + "string": basetypes.StringType{}, + }, + Optional: true, + Computed: true, + Sensitive: true, + }, + }, + Blocks: make(map[string]resourceschema.Block, 0), + }, + }, + "tuple-error": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "tuple", + Type: tftypes.Tuple{ + ElementTypes: []tftypes.Type{ + tftypes.Bool, + tftypes.Number, + tftypes.String, + }, + }, + Required: true, + }, + }, + }, + }, + expectedErr: `no supported attribute for "tuple", type: tftypes.Tuple`, + }, + "list-nested": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "list_nested", + Required: true, + NestedType: &tfprotov6.SchemaObject{ + Nesting: tfprotov6.SchemaObjectNestingModeList, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "list_of_strings", + Type: tftypes.List{ElementType: tftypes.String}, + Computed: true, + }, + { + Name: "nested_list_attr", + Required: true, + NestedType: &tfprotov6.SchemaObject{ + Nesting: tfprotov6.SchemaObjectNestingModeList, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Blocks: make(map[string]resourceschema.Block, 0), + Attributes: map[string]resourceschema.Attribute{ + "list_nested": resourceschema.ListNestedAttribute{ + Required: true, + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: map[string]resourceschema.Attribute{ + "list_of_strings": resourceschema.ListAttribute{ + ElementType: basetypes.StringType{}, + Computed: true, + }, + "nested_list_attr": resourceschema.ListNestedAttribute{ + Required: true, + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "list-block": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + BlockTypes: []*tfprotov6.SchemaNestedBlock{ + { + TypeName: "list_block", + Nesting: tfprotov6.SchemaNestedBlockNestingModeList, + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "list_of_strings", + Type: tftypes.List{ElementType: tftypes.String}, + Computed: true, + }, + }, + BlockTypes: []*tfprotov6.SchemaNestedBlock{ + { + TypeName: "nested_list_block", + Nesting: tfprotov6.SchemaNestedBlockNestingModeList, + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Blocks: map[string]resourceschema.Block{ + "list_block": resourceschema.ListNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: map[string]resourceschema.Attribute{ + "list_of_strings": resourceschema.ListAttribute{ + ElementType: basetypes.StringType{}, + Computed: true, + }, + }, + Blocks: map[string]resourceschema.Block{ + "nested_list_block": resourceschema.ListNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + Attributes: make(map[string]resourceschema.Attribute, 0), + }, + }, + "set-nested": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "set_nested", + Required: true, + NestedType: &tfprotov6.SchemaObject{ + Nesting: tfprotov6.SchemaObjectNestingModeSet, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "set_of_strings", + Type: tftypes.Set{ElementType: tftypes.String}, + Computed: true, + }, + { + Name: "nested_set_attr", + Required: true, + NestedType: &tfprotov6.SchemaObject{ + Nesting: tfprotov6.SchemaObjectNestingModeSet, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Blocks: make(map[string]resourceschema.Block, 0), + Attributes: map[string]resourceschema.Attribute{ + "set_nested": resourceschema.SetNestedAttribute{ + Required: true, + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: map[string]resourceschema.Attribute{ + "set_of_strings": resourceschema.SetAttribute{ + ElementType: basetypes.StringType{}, + Computed: true, + }, + "nested_set_attr": resourceschema.SetNestedAttribute{ + Required: true, + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "set-block": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + BlockTypes: []*tfprotov6.SchemaNestedBlock{ + { + TypeName: "set_block", + Nesting: tfprotov6.SchemaNestedBlockNestingModeSet, + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "set_of_strings", + Type: tftypes.Set{ElementType: tftypes.String}, + Computed: true, + }, + }, + BlockTypes: []*tfprotov6.SchemaNestedBlock{ + { + TypeName: "nested_set_block", + Nesting: tfprotov6.SchemaNestedBlockNestingModeSet, + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Blocks: map[string]resourceschema.Block{ + "set_block": resourceschema.SetNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: map[string]resourceschema.Attribute{ + "set_of_strings": resourceschema.SetAttribute{ + ElementType: basetypes.StringType{}, + Computed: true, + }, + }, + Blocks: map[string]resourceschema.Block{ + "nested_set_block": resourceschema.SetNestedBlock{ + NestedObject: resourceschema.NestedBlockObject{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + Attributes: make(map[string]resourceschema.Attribute, 0), + }, + }, + "single-nested": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "single_nested", + Required: true, + NestedType: &tfprotov6.SchemaObject{ + Nesting: tfprotov6.SchemaObjectNestingModeSingle, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "dynamic", + Type: tftypes.DynamicPseudoType, + Computed: true, + }, + { + Name: "nested_single_attr", + Required: true, + NestedType: &tfprotov6.SchemaObject{ + Nesting: tfprotov6.SchemaObjectNestingModeSingle, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Blocks: make(map[string]resourceschema.Block, 0), + Attributes: map[string]resourceschema.Attribute{ + "single_nested": resourceschema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]resourceschema.Attribute{ + "dynamic": resourceschema.DynamicAttribute{ + Computed: true, + }, + "nested_single_attr": resourceschema.SingleNestedAttribute{ + Required: true, + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + "single-block": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + BlockTypes: []*tfprotov6.SchemaNestedBlock{ + { + TypeName: "single_block", + Nesting: tfprotov6.SchemaNestedBlockNestingModeSingle, + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "map_of_strings", + Type: tftypes.Map{ElementType: tftypes.String}, + Computed: true, + }, + }, + BlockTypes: []*tfprotov6.SchemaNestedBlock{ + { + TypeName: "nested_single_block", + Nesting: tfprotov6.SchemaNestedBlockNestingModeSingle, + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Blocks: map[string]resourceschema.Block{ + "single_block": resourceschema.SingleNestedBlock{ + Attributes: map[string]resourceschema.Attribute{ + "map_of_strings": resourceschema.MapAttribute{ + ElementType: basetypes.StringType{}, + Computed: true, + }, + }, + Blocks: map[string]resourceschema.Block{ + "nested_single_block": resourceschema.SingleNestedBlock{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Required: true, + }, + }, + }, + }, + }, + }, + Attributes: make(map[string]resourceschema.Attribute, 0), + }, + }, + "map-nested": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "map_nested", + Required: true, + NestedType: &tfprotov6.SchemaObject{ + Nesting: tfprotov6.SchemaObjectNestingModeMap, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "map_of_strings", + Type: tftypes.Map{ElementType: tftypes.String}, + Computed: true, + }, + { + Name: "nested_map_attr", + Required: true, + NestedType: &tfprotov6.SchemaObject{ + Nesting: tfprotov6.SchemaObjectNestingModeMap, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Blocks: make(map[string]resourceschema.Block, 0), + Attributes: map[string]resourceschema.Attribute{ + "map_nested": resourceschema.MapNestedAttribute{ + Required: true, + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: map[string]resourceschema.Attribute{ + "map_of_strings": resourceschema.MapAttribute{ + ElementType: basetypes.StringType{}, + Computed: true, + }, + "nested_map_attr": resourceschema.MapNestedAttribute{ + Required: true, + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "map-block": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + BlockTypes: []*tfprotov6.SchemaNestedBlock{ + { + TypeName: "map_block", + Nesting: tfprotov6.SchemaNestedBlockNestingModeMap, + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + }, + }, + }, + }, + }, + }, + expectedErr: `no supported block for nesting mode MAP in nested block "map_block"`, + }, + "block-with-nested-attr": { + input: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + BlockTypes: []*tfprotov6.SchemaNestedBlock{ + { + TypeName: "single_block", + Nesting: tfprotov6.SchemaNestedBlockNestingModeSingle, + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "list_nested", + Required: true, + NestedType: &tfprotov6.SchemaObject{ + Nesting: tfprotov6.SchemaObjectNestingModeList, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "number", + Type: tftypes.Number, + Computed: true, + }, + { + Name: "nested_map_attr", + Required: true, + NestedType: &tfprotov6.SchemaObject{ + Nesting: tfprotov6.SchemaObjectNestingModeMap, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expected: &resourceschema.Schema{ + Blocks: map[string]resourceschema.Block{ + "single_block": resourceschema.SingleNestedBlock{ + Attributes: map[string]resourceschema.Attribute{ + "list_nested": resourceschema.ListNestedAttribute{ + Required: true, + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: map[string]resourceschema.Attribute{ + "number": resourceschema.NumberAttribute{ + Computed: true, + }, + "nested_map_attr": resourceschema.MapNestedAttribute{ + Required: true, + NestedObject: resourceschema.NestedAttributeObject{ + Attributes: map[string]resourceschema.Attribute{ + "bool": resourceschema.BoolAttribute{ + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Attributes: make(map[string]resourceschema.Attribute, 0), + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := fromproto6.ResourceSchema(context.Background(), tc.input) + if err != nil { + if tc.expectedErr == "" { + t.Errorf("Unexpected error: %s", err) + return + } + if err.Error() != tc.expectedErr { + t.Errorf("Expected error to be %q, got %q", tc.expectedErr, err.Error()) + return + } + // got expected error + return + } + if tc.expectedErr != "" { + t.Errorf("Expected error to be %q, got nil", tc.expectedErr) + return + } + if diff := cmp.Diff(got, tc.expected); diff != "" { + t.Errorf("Unexpected diff (+wanted, -got): %s", diff) + return + } + }) + } +} From 9a2bf68d9dce6c100ba299a751ac93a9d7320e65 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 5 Aug 2025 19:23:11 -0400 Subject: [PATCH 16/18] toproto and proto6server --- internal/proto6server/server_invokeaction.go | 10 +- .../proto6server/server_invokeaction_test.go | 1142 ++++++++++++++++- .../proto6server/server_linked_resources.go | 92 ++ internal/proto6server/server_planaction.go | 10 +- .../proto6server/server_planaction_test.go | 1096 +++++++++++++++- internal/toproto6/action_schema.go | 12 +- internal/toproto6/action_schema_test.go | 75 ++ internal/toproto6/invoke_action_event.go | 27 +- internal/toproto6/invoke_action_event_test.go | 123 ++ internal/toproto6/planaction.go | 15 +- internal/toproto6/planaction_test.go | 128 +- 11 files changed, 2665 insertions(+), 65 deletions(-) create mode 100644 internal/proto6server/server_linked_resources.go diff --git a/internal/proto6server/server_invokeaction.go b/internal/proto6server/server_invokeaction.go index 3803dcdea..8f973f733 100644 --- a/internal/proto6server/server_invokeaction.go +++ b/internal/proto6server/server_invokeaction.go @@ -52,7 +52,15 @@ func (s *Server) InvokeAction(ctx context.Context, proto6Req *tfprotov6.InvokeAc return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) } - fwReq, diags := fromproto6.InvokeActionRequest(ctx, proto6Req, action, actionSchema) + lrSchemas, lrIdentitySchemas, diags := s.LinkedResourceSchemas(ctx, actionSchema) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) + } + + fwReq, diags := fromproto6.InvokeActionRequest(ctx, proto6Req, action, actionSchema, lrSchemas, lrIdentitySchemas) fwResp.Diagnostics.Append(diags...) diff --git a/internal/proto6server/server_invokeaction_test.go b/internal/proto6server/server_invokeaction_test.go index 0c36bf980..878904098 100644 --- a/internal/proto6server/server_invokeaction_test.go +++ b/internal/proto6server/server_invokeaction_test.go @@ -11,10 +11,15 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/action" - "github.com/hashicorp/terraform-plugin-framework/action/schema" + actionschema "github.com/hashicorp/terraform-plugin-framework/action/schema" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -28,40 +33,747 @@ func TestServerInvokeAction(t *testing.T) { }, } - testConfigDynamicValue := testNewDynamicValue(t, testType, map[string]tftypes.Value{ + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_computed": resourceschema.StringAttribute{ + Computed: true, + }, + "test_required": resourceschema.StringAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceSchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_computed": tftypes.String, + "test_required": tftypes.String, + }, + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testLinkedResourceIdentitySchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testActionConfigDynamicValue := testNewDynamicValue(t, testType, map[string]tftypes.Value{ "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), }) - testEmptyDynamicValue := testNewDynamicValue(t, tftypes.Object{}, nil) + testEmptyDynamicValue := testNewDynamicValue(t, tftypes.Object{}, nil) + + testUnlinkedSchema := actionschema.UnlinkedSchema{ + Attributes: map[string]actionschema.Attribute{ + "test_required": actionschema.StringAttribute{ + Required: true, + }, + }, + } + + testLifecycleSchema := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.LinkedResource{ + TypeName: "test_linked_resource", + }, + } + + testLifecycleSchemaRaw := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV6LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + IdentitySchema: func() *tfprotov6.ResourceIdentitySchema { + return &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "test_id", + Type: tftypes.String, + RequiredForImport: true, + }, + }, + } + }, + }, + } + + testLifecycleSchemaRawNoIdentity := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV6LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + }, + } + + testCases := map[string]struct { + server *Server + request *tfprotov6.InvokeActionRequest + expectedError error + expectedEvents []tfprotov6.InvokeActionEvent + }{ + "no-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.UnlinkedSchema{} + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{}, + }, + }, + }, + }, + "request-config": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testUnlinkedSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var config struct { + TestRequired types.String `tfsdk:"test_required"` + } + + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + + if config.TestRequired.ValueString() != "test-config-value" { + resp.Diagnostics.AddError("unexpected req.Config value: %s", config.TestRequired.ValueString()) + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testActionConfigDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{}, + }, + }, + }, + }, + "request-linkedresource-no-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.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + }, + }, + }, + "request-linkedresource-with-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) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + var linkedResourceIdentityData struct { + TestID types.String `tfsdk:"test_id"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Identity.Get(ctx, &linkedResourceIdentityData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceIdentityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"id-123\", got: %s", linkedResourceIdentityData.TestID), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + NewIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + }, + }, + }, + "request-raw-linkedresource-no-identity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchemaRawNoIdentity + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + }, + }, + }, + "request-raw-linkedresource-with-identity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchemaRaw + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + var linkedResourceIdentityData struct { + TestID types.String `tfsdk:"test_id"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } - testUnlinkedSchema := schema.UnlinkedSchema{ - Attributes: map[string]schema.Attribute{ - "test_required": schema.StringAttribute{ - Required: true, + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Identity.Get(ctx, &linkedResourceIdentityData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceIdentityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"id-123\", got: %s", linkedResourceIdentityData.TestID), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + NewIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + }, }, }, - } - - testCases := map[string]struct { - server *Server - request *tfprotov6.InvokeActionRequest - expectedError error - expectedEvents []tfprotov6.InvokeActionEvent - }{ - "no-schema": { + "response-linkedresource-no-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.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + } + }, + } + }, ActionsMethod: func(_ context.Context) []func() action.Action { return []func() action.Action{ func() action.Action { return &testprovider.Action{ SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { - resp.Schema = schema.UnlinkedSchema{} + resp.Schema = testLifecycleSchema }, MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { resp.TypeName = "test_action" }, + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + // Should be copied over from request + if len(resp.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected resp.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(resp.LinkedResources[0].State.SetAttribute(ctx, path.Root("test_computed"), "new-state-value")...) + if resp.Diagnostics.HasError() { + return + } + }, } }, } @@ -72,36 +784,85 @@ func TestServerInvokeAction(t *testing.T) { request: &tfprotov6.InvokeActionRequest{ Config: testEmptyDynamicValue, ActionType: "test_action", + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, }, expectedEvents: []tfprotov6.InvokeActionEvent{ { - Type: tfprotov6.CompletedInvokeActionEventType{}, + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, }, }, }, - "request-config": { + "response-linkedresource-with-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) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, ActionsMethod: func(_ context.Context) []func() action.Action { return []func() action.Action{ func() action.Action { return &testprovider.Action{ SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { - resp.Schema = testUnlinkedSchema + resp.Schema = testLifecycleSchema }, MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { resp.TypeName = "test_action" }, InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { - var config struct { - TestRequired types.String `tfsdk:"test_required"` + // Should be copied over from request + if len(resp.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected resp.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) } - resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + resp.Diagnostics.Append(resp.LinkedResources[0].State.SetAttribute(ctx, path.Root("test_computed"), "new-state-value")...) + if resp.Diagnostics.HasError() { + return + } - if config.TestRequired.ValueString() != "test-config-value" { - resp.Diagnostics.AddError("unexpected req.Config value: %s", config.TestRequired.ValueString()) + resp.Diagnostics.Append(resp.LinkedResources[0].Identity.SetAttribute(ctx, path.Root("test_id"), "new-id-123")...) + if resp.Diagnostics.HasError() { + return } }, } @@ -112,12 +873,47 @@ func TestServerInvokeAction(t *testing.T) { }, }, request: &tfprotov6.InvokeActionRequest{ - Config: testConfigDynamicValue, + Config: testEmptyDynamicValue, ActionType: "test_action", + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, }, expectedEvents: []tfprotov6.InvokeActionEvent{ { - Type: tfprotov6.CompletedInvokeActionEventType{}, + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{ + { + NewState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + NewIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + }, + }, + }, + }, }, }, }, @@ -148,7 +944,7 @@ func TestServerInvokeAction(t *testing.T) { }, }, request: &tfprotov6.InvokeActionRequest{ - Config: testConfigDynamicValue, + Config: testActionConfigDynamicValue, ActionType: "test_action", }, expectedEvents: []tfprotov6.InvokeActionEvent{ @@ -168,7 +964,9 @@ func TestServerInvokeAction(t *testing.T) { }, }, { - Type: tfprotov6.CompletedInvokeActionEventType{}, + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{}, + }, }, }, }, @@ -198,12 +996,13 @@ func TestServerInvokeAction(t *testing.T) { }, }, request: &tfprotov6.InvokeActionRequest{ - Config: testConfigDynamicValue, + Config: testActionConfigDynamicValue, ActionType: "test_action", }, expectedEvents: []tfprotov6.InvokeActionEvent{ { Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{}, Diagnostics: []*tfprotov6.Diagnostic{ { Severity: tfprotov6.DiagnosticSeverityWarning, @@ -250,7 +1049,7 @@ func TestServerInvokeAction(t *testing.T) { }, }, request: &tfprotov6.InvokeActionRequest{ - Config: testConfigDynamicValue, + Config: testActionConfigDynamicValue, ActionType: "test_action", }, expectedEvents: []tfprotov6.InvokeActionEvent{ @@ -281,6 +1080,7 @@ func TestServerInvokeAction(t *testing.T) { }, { Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{}, Diagnostics: []*tfprotov6.Diagnostic{ { Severity: tfprotov6.DiagnosticSeverityWarning, @@ -297,6 +1097,286 @@ func TestServerInvokeAction(t *testing.T) { }, }, }, + "response-linkedresource-not-found": { + 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 = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_not_the_right_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_linked_resource\" linked resource data from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "The \"test_linked_resource\" linked resource was not found on the provider server.", + }, + }, + }, + }, + }, + }, + "response-raw-linkedresource-invalid-resource-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV6LinkedResource{ + TypeName: "test_invalid_linked_resource", + Schema: func() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + // Tuple is not supported in framework + { + Name: "test_tuple", + Type: tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.Bool}}, + Required: true, + }, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_invalid_linked_resource\" linked resource schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "no supported attribute for \"test_tuple\", type: tftypes.Tuple", + }, + }, + }, + }, + }, + }, + "response-raw-linkedresource-invalid-identity-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV6LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + IdentitySchema: func() *tfprotov6.ResourceIdentitySchema { + return &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + // Set is not a valid type for resource identity + { + Name: "test_id", + Type: tftypes.Set{ElementType: tftypes.Bool}, + RequiredForImport: true, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_linked_resource\" linked resource identity schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "no supported identity attribute for \"test_id\", type: tftypes.Set", + }, + }, + }, + }, + }, + }, + "response-raw-linkedresource-v5-resource-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV5LinkedResource{ + TypeName: "test_v5_linked_resource", + Schema: func() *tfprotov5.Schema { + return &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_v5_linked_resource\" linked resource schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "The \"test_v5_linked_resource\" linked resource is a protocol v5 resource but the provider is being served using protocol v6.", + }, + }, + }, + }, + }, + }, } for name, testCase := range testCases { diff --git a/internal/proto6server/server_linked_resources.go b/internal/proto6server/server_linked_resources.go new file mode 100644 index 000000000..2093bcb55 --- /dev/null +++ b/internal/proto6server/server_linked_resources.go @@ -0,0 +1,92 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package proto6server + +import ( + "context" + "fmt" + + actionschema "github.com/hashicorp/terraform-plugin-framework/action/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto6" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" +) + +// LinkedResourceSchemas returns the linked resource schemas for the given Action Schema. Linked resource schemas +// are either retrieved from the provider server or converted from the action schema definition. +func (s *Server) LinkedResourceSchemas(ctx context.Context, actionSchema actionschema.SchemaType) ([]fwschema.Schema, []fwschema.Schema, diag.Diagnostics) { + allDiags := make(diag.Diagnostics, 0) + lrSchemas := make([]fwschema.Schema, 0) + lrIdentitySchemas := make([]fwschema.Schema, 0) + + for _, lrType := range actionSchema.LinkedResourceTypes() { + switch lrType := lrType.(type) { + case actionschema.RawV5LinkedResource: + allDiags.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "The %[1]q linked resource is a protocol v5 resource but the provider is being served using protocol v6.", lrType.GetTypeName()), + ) + + return nil, nil, allDiags + case actionschema.RawV6LinkedResource: + // Raw linked resources are not stored on this provider server, so we retrieve the schemas from the + // action definition directly and convert them to framework schemas. + lrSchema, err := fromproto6.ResourceSchema(ctx, lrType.GetSchema()) + if err != nil { + allDiags.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %q linked resource schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), + ) + + return nil, nil, allDiags + } + lrSchemas = append(lrSchemas, lrSchema) + + lrIdentitySchema, err := fromproto6.IdentitySchema(ctx, lrType.GetIdentitySchema()) + if err != nil { + allDiags.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %q linked resource identity schema from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n%s", lrType.GetTypeName(), err.Error()), + ) + + return nil, nil, allDiags + } + lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) + default: + // Any other linked resource type should be stored on the same provider server as the action, + // so we can just retrieve it via the type name. + lrSchema, diags := s.FrameworkServer.ResourceSchema(ctx, lrType.GetTypeName()) + if diags.HasError() { + allDiags.AddError( + "Invalid Linked Resource Schema", + fmt.Sprintf("An unexpected error was encountered when converting %[1]q linked resource data from the protocol type. "+ + "This is always an issue in the provider code and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "The %[1]q linked resource was not found on the provider server.", lrType.GetTypeName()), + ) + + return nil, nil, allDiags + } + lrSchemas = append(lrSchemas, lrSchema) + + lrIdentitySchema, diags := s.FrameworkServer.ResourceIdentitySchema(ctx, lrType.GetTypeName()) + allDiags.Append(diags...) + if allDiags.HasError() { + // If the resource is found, the identity schema will only return a diagnostic if the provider implementation + // returns an error from (resource.Resource).IdentitySchema method. + return nil, nil, allDiags + } + lrIdentitySchemas = append(lrIdentitySchemas, lrIdentitySchema) + } + } + + return lrSchemas, lrIdentitySchemas, allDiags +} diff --git a/internal/proto6server/server_planaction.go b/internal/proto6server/server_planaction.go index a92a28d63..60a5bac07 100644 --- a/internal/proto6server/server_planaction.go +++ b/internal/proto6server/server_planaction.go @@ -36,7 +36,15 @@ func (s *Server) PlanAction(ctx context.Context, proto6Req *tfprotov6.PlanAction return toproto6.PlanActionResponse(ctx, fwResp), nil } - fwReq, diags := fromproto6.PlanActionRequest(ctx, proto6Req, action, actionSchema) + lrSchemas, lrIdentitySchemas, diags := s.LinkedResourceSchemas(ctx, actionSchema) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto6.PlanActionResponse(ctx, fwResp), nil + } + + fwReq, diags := fromproto6.PlanActionRequest(ctx, proto6Req, action, actionSchema, lrSchemas, lrIdentitySchemas) fwResp.Diagnostics.Append(diags...) diff --git a/internal/proto6server/server_planaction_test.go b/internal/proto6server/server_planaction_test.go index 8d4093e2f..a4a3e9d3d 100644 --- a/internal/proto6server/server_planaction_test.go +++ b/internal/proto6server/server_planaction_test.go @@ -5,14 +5,20 @@ package proto6server import ( "context" + "fmt" "testing" "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/action" - "github.com/hashicorp/terraform-plugin-framework/action/schema" + actionschema "github.com/hashicorp/terraform-plugin-framework/action/schema" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -26,20 +32,120 @@ func TestServerPlanAction(t *testing.T) { }, } - testConfigDynamicValue := testNewDynamicValue(t, testType, map[string]tftypes.Value{ + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_computed": resourceschema.StringAttribute{ + Computed: true, + }, + "test_required": resourceschema.StringAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceSchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_computed": tftypes.String, + "test_required": tftypes.String, + }, + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + + testLinkedResourceIdentitySchemaType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testActionConfigDynamicValue := testNewDynamicValue(t, testType, map[string]tftypes.Value{ "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), }) testEmptyDynamicValue := testNewDynamicValue(t, tftypes.Object{}, nil) - testUnlinkedSchema := schema.UnlinkedSchema{ - Attributes: map[string]schema.Attribute{ - "test_required": schema.StringAttribute{ + testUnlinkedSchema := actionschema.UnlinkedSchema{ + Attributes: map[string]actionschema.Attribute{ + "test_required": actionschema.StringAttribute{ Required: true, }, }, } + testLifecycleSchema := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.LinkedResource{ + TypeName: "test_linked_resource", + }, + } + + testLifecycleSchemaRaw := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV6LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + IdentitySchema: func() *tfprotov6.ResourceIdentitySchema { + return &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "test_id", + Type: tftypes.String, + RequiredForImport: true, + }, + }, + } + }, + }, + } + + testLifecycleSchemaRawNoIdentity := actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV6LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + }, + } + testCases := map[string]struct { server *Server request *tfprotov6.PlanActionRequest @@ -55,7 +161,7 @@ func TestServerPlanAction(t *testing.T) { func() action.Action { return &testprovider.Action{ SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { - resp.Schema = schema.UnlinkedSchema{} + resp.Schema = actionschema.UnlinkedSchema{} }, MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { resp.TypeName = "test_action" @@ -71,7 +177,9 @@ func TestServerPlanAction(t *testing.T) { Config: testEmptyDynamicValue, ActionType: "test_action", }, - expectedResponse: &tfprotov6.PlanActionResponse{}, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{}, + }, }, "request-config": { server: &Server{ @@ -108,30 +216,91 @@ func TestServerPlanAction(t *testing.T) { }, }, request: &tfprotov6.PlanActionRequest{ - Config: testConfigDynamicValue, + Config: testActionConfigDynamicValue, ActionType: "test_action", }, - expectedResponse: &tfprotov6.PlanActionResponse{}, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{}, + }, }, - "response-diagnostics": { + "request-linkedresource-no-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.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + } + }, + } + }, ActionsMethod: func(_ context.Context) []func() action.Action { return []func() action.Action{ func() action.Action { return &testprovider.ActionWithModifyPlan{ Action: &testprovider.Action{ SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { - resp.Schema = testUnlinkedSchema + resp.Schema = testLifecycleSchema }, MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { resp.TypeName = "test_action" }, }, ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { - resp.Diagnostics.AddWarning("warning summary", "warning detail") - resp.Diagnostics.AddError("error summary", "error detail") + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } }, } }, @@ -141,20 +310,905 @@ func TestServerPlanAction(t *testing.T) { }, }, request: &tfprotov6.PlanActionRequest{ - Config: testConfigDynamicValue, + Config: testEmptyDynamicValue, ActionType: "test_action", + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, }, expectedResponse: &tfprotov6.PlanActionResponse{ - Diagnostics: []*tfprotov6.Diagnostic{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{ { - Severity: tfprotov6.DiagnosticSeverityWarning, - Summary: "warning summary", - Detail: "warning detail", + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + }, + "request-linkedresource-with-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) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + var linkedResourceIdentityData struct { + TestID types.String `tfsdk:"test_id"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Identity.Get(ctx, &linkedResourceIdentityData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceIdentityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"id-123\", got: %s", linkedResourceIdentityData.TestID), + ) + return + } + }, + } + }, + } + }, }, + }, + }, + request: &tfprotov6.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.ProposedLinkedResource{ { - Severity: tfprotov6.DiagnosticSeverityError, - Summary: "error summary", - Detail: "error detail", + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, 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, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{ + { + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, 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, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + }, + "request-raw-linkedresource-no-identity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchemaRawNoIdentity + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{ + { + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + }, + "request-raw-linkedresource-with-identity": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchemaRaw + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + var linkedResourceData struct { + TestRequired types.String `tfsdk:"test_required"` + TestComputed types.String `tfsdk:"test_computed"` + } + var linkedResourceIdentityData struct { + TestID types.String `tfsdk:"test_id"` + } + + if len(req.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected req.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(req.LinkedResources[0].Plan.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsUnknown() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be unknown, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Config.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if !linkedResourceData.TestComputed.IsNull() { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be null, got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].State.Get(ctx, &linkedResourceData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceData.TestComputed.ValueString() != "test-state-value" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"test-state-value\", got: %s", linkedResourceData.TestComputed), + ) + return + } + + resp.Diagnostics.Append(req.LinkedResources[0].Identity.Get(ctx, &linkedResourceIdentityData)...) + if resp.Diagnostics.HasError() { + return + } + + if linkedResourceIdentityData.TestID.ValueString() != "id-123" { + resp.Diagnostics.AddError( + "unexpected req.LinkedResources value", + fmt.Sprintf("expected linked resource data to be \"id-123\", got: %s", linkedResourceIdentityData.TestID), + ) + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, 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, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{ + { + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, 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, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + }, + "response-linkedresource-no-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.Resource{ + SchemaMethod: func(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + // Should be copied over from request + if len(resp.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected resp.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(resp.LinkedResources[0].Plan.SetAttribute(ctx, path.Root("test_computed"), "new-plan-value")...) + if resp.Diagnostics.HasError() { + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{ + { + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-plan-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + }, + }, + }, + }, + "response-linkedresource-with-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) { + resp.Schema = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_linked_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + // Should be copied over from request + if len(resp.LinkedResources) != 1 { + resp.Diagnostics.AddError("unexpected resp.LinkedResources value", fmt.Sprintf("got %d, expected 1", len(req.LinkedResources))) + } + + resp.Diagnostics.Append(resp.LinkedResources[0].Plan.SetAttribute(ctx, path.Root("test_computed"), "new-plan-value")...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.LinkedResources[0].Identity.SetAttribute(ctx, path.Root("test_id"), "new-id-123")...) + if resp.Diagnostics.HasError() { + return + } + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-state-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Config: testNewDynamicValue(t, testLinkedResourceSchemaType, 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, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }), + }, + }, + }, + }, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{ + { + PlannedState: testNewDynamicValue(t, testLinkedResourceSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "new-plan-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: testNewDynamicValue(t, testLinkedResourceIdentitySchemaType, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "new-id-123"), + }), + }, + }, + }, + }, + }, + "response-diagnostics": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.ActionWithModifyPlan{ + Action: &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testUnlinkedSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + resp.Diagnostics.AddWarning("warning summary", "warning detail") + resp.Diagnostics.AddError("error summary", "error detail") + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanActionRequest{ + Config: testActionConfigDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{}, + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + }, + }, + "response-linkedresource-not-found": { + 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 = testLinkedResourceSchema + }, + MetadataMethod: func(_ context.Context, _ resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "test_not_the_right_resource" + }, + }, + IdentitySchemaMethod: func(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + resp.IdentitySchema = testLinkedResourceIdentitySchema + }, + } + }, + } + }, + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = testLifecycleSchema + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{}, + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_linked_resource\" linked resource data from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "The \"test_linked_resource\" linked resource was not found on the provider server.", + }, + }, + }, + }, + "response-raw-linkedresource-invalid-resource-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV6LinkedResource{ + TypeName: "test_invalid_linked_resource", + Schema: func() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + // Tuple is not supported in framework + { + Name: "test_tuple", + Type: tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.Bool}}, + Required: true, + }, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{}, + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_invalid_linked_resource\" linked resource schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "no supported attribute for \"test_tuple\", type: tftypes.Tuple", + }, + }, + }, + }, + "response-raw-linkedresource-invalid-identity-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV6LinkedResource{ + TypeName: "test_linked_resource", + Schema: func() *tfprotov6.Schema { + return &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + IdentitySchema: func() *tfprotov6.ResourceIdentitySchema { + return &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + // Set is not a valid type for resource identity + { + Name: "test_id", + Type: tftypes.Set{ElementType: tftypes.Bool}, + RequiredForImport: true, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{}, + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_linked_resource\" linked resource identity schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "no supported identity attribute for \"test_id\", type: tftypes.Set", + }, + }, + }, + }, + "response-raw-linkedresource-v5-resource-schema": { + server: &Server{ + FrameworkServer: fwserver.Server{ + Provider: &testprovider.Provider{ + ActionsMethod: func(_ context.Context) []func() action.Action { + return []func() action.Action{ + func() action.Action { + return &testprovider.Action{ + SchemaMethod: func(_ context.Context, _ action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = actionschema.LifecycleSchema{ + Attributes: map[string]actionschema.Attribute{}, + LinkedResource: actionschema.RawV5LinkedResource{ + TypeName: "test_v5_linked_resource", + Schema: func() *tfprotov5.Schema { + return &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_computed", + Type: tftypes.String, + Computed: true, + }, + { + Name: "test_required", + Type: tftypes.String, + Required: true, + }, + }, + }, + } + }, + }, + } + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + // No data setup needed, should error before decoding logic + }, + }, + }, + expectedResponse: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{}, + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Invalid Linked Resource Schema", + Detail: "An unexpected error was encountered when converting \"test_v5_linked_resource\" linked resource schema from the protocol type. " + + "This is always an issue in the provider code and should be reported to the provider developers.\n\nPlease report this to the provider developer:\n\n" + + "The \"test_v5_linked_resource\" linked resource is a protocol v5 resource but the provider is being served using protocol v6.", }, }, }, diff --git a/internal/toproto6/action_schema.go b/internal/toproto6/action_schema.go index 94aaa7416..1ef4c734a 100644 --- a/internal/toproto6/action_schema.go +++ b/internal/toproto6/action_schema.go @@ -26,10 +26,18 @@ func ActionSchema(ctx context.Context, s actionschema.SchemaType) (*tfprotov6.Ac Schema: configSchema, } - // TODO:Actions: Implement linked and lifecycle action schema types - switch s.(type) { + // TODO:Actions: Implement linked action schema type + switch schema := s.(type) { case actionschema.UnlinkedSchema: result.Type = tfprotov6.UnlinkedActionSchemaType{} + case actionschema.LifecycleSchema: + result.Type = tfprotov6.LifecycleActionSchemaType{ + Executes: tfprotov6.LifecycleExecutionOrder(schema.ExecutionOrder), + LinkedResource: &tfprotov6.LinkedResourceSchema{ + TypeName: schema.LinkedResource.GetTypeName(), + Description: schema.LinkedResource.GetDescription(), + }, + } default: // It is not currently possible to create [actionschema.SchemaType] // implementations outside the "action/schema" package. If this error was reached, diff --git a/internal/toproto6/action_schema_test.go b/internal/toproto6/action_schema_test.go index ab145a2d7..36e4ea70a 100644 --- a/internal/toproto6/action_schema_test.go +++ b/internal/toproto6/action_schema_test.go @@ -92,6 +92,81 @@ func TestActionSchema(t *testing.T) { }, }, }, + "lifecycle": { + input: actionschema.LifecycleSchema{ + ExecutionOrder: actionschema.ExecutionOrderAfter, + LinkedResource: actionschema.LinkedResource{ + TypeName: "test_linked_resource", + Description: "A linked resource for this action", + }, + Attributes: map[string]actionschema.Attribute{ + "bool": actionschema.BoolAttribute{ + Optional: true, + }, + "string": actionschema.StringAttribute{ + Required: true, + }, + }, + Blocks: map[string]actionschema.Block{ + "single_block": actionschema.SingleNestedBlock{ + Attributes: map[string]actionschema.Attribute{ + "bool": actionschema.BoolAttribute{ + Required: true, + }, + "string": actionschema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + }, + expected: &tfprotov6.ActionSchema{ + Type: tfprotov6.LifecycleActionSchemaType{ + Executes: tfprotov6.LifecycleExecutionOrderAfter, + LinkedResource: &tfprotov6.LinkedResourceSchema{ + TypeName: "test_linked_resource", + Description: "A linked resource for this action", + }, + }, + Schema: &tfprotov6.Schema{ + Version: 0, + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Optional: true, + }, + { + Name: "string", + Type: tftypes.String, + Required: true, + }, + }, + BlockTypes: []*tfprotov6.SchemaNestedBlock{ + { + TypeName: "single_block", + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "bool", + Type: tftypes.Bool, + Required: true, + }, + { + Name: "string", + Type: tftypes.String, + Optional: true, + }, + }, + }, + Nesting: tfprotov6.SchemaNestedBlockNestingModeSingle, + }, + }, + }, + }, + }, + }, } for name, tc := range tests { diff --git a/internal/toproto6/invoke_action_event.go b/internal/toproto6/invoke_action_event.go index c4410ae4e..a67de144d 100644 --- a/internal/toproto6/invoke_action_event.go +++ b/internal/toproto6/invoke_action_event.go @@ -18,11 +18,28 @@ func ProgressInvokeActionEventType(ctx context.Context, event fwserver.InvokePro } } -func CompletedInvokeActionEventType(ctx context.Context, event *fwserver.InvokeActionResponse) tfprotov6.InvokeActionEvent { +func CompletedInvokeActionEventType(ctx context.Context, fw *fwserver.InvokeActionResponse) tfprotov6.InvokeActionEvent { + completedEvent := tfprotov6.CompletedInvokeActionEventType{ + Diagnostics: Diagnostics(ctx, fw.Diagnostics), + } + + completedEvent.LinkedResources = make([]*tfprotov6.NewLinkedResource, len(fw.LinkedResources)) + + for i, linkedResource := range fw.LinkedResources { + newState, diags := State(ctx, linkedResource.NewState) + completedEvent.Diagnostics = append(completedEvent.Diagnostics, Diagnostics(ctx, diags)...) + + newIdentity, diags := ResourceIdentity(ctx, linkedResource.NewIdentity) + completedEvent.Diagnostics = append(completedEvent.Diagnostics, Diagnostics(ctx, diags)...) + + completedEvent.LinkedResources[i] = &tfprotov6.NewLinkedResource{ + NewState: newState, + NewIdentity: newIdentity, + RequiresReplace: linkedResource.RequiresReplace, + } + } + return tfprotov6.InvokeActionEvent{ - Type: tfprotov6.CompletedInvokeActionEventType{ - // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented - Diagnostics: Diagnostics(ctx, event.Diagnostics), - }, + Type: completedEvent, } } diff --git a/internal/toproto6/invoke_action_event_test.go b/internal/toproto6/invoke_action_event_test.go index 6e4ad5893..659bdaf21 100644 --- a/internal/toproto6/invoke_action_event_test.go +++ b/internal/toproto6/invoke_action_event_test.go @@ -11,7 +11,11 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" ) func TestProgressInvokeActionEventType(t *testing.T) { @@ -49,10 +53,128 @@ func TestProgressInvokeActionEventType(t *testing.T) { func TestCompletedInvokeActionEventType(t *testing.T) { t.Parallel() + testLinkedResourceProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute_one": tftypes.String, + "test_attribute_two": tftypes.Bool, + }, + } + + testLinkedResourceProto6Value := tftypes.NewValue(testLinkedResourceProto6Type, map[string]tftypes.Value{ + "test_attribute_one": tftypes.NewValue(tftypes.String, "test-value-1"), + "test_attribute_two": tftypes.NewValue(tftypes.Bool, true), + }) + + testLinkedResourceProto6DynamicValue, err := tfprotov6.NewDynamicValue(testLinkedResourceProto6Type, testLinkedResourceProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_attribute_one": resourceschema.StringAttribute{ + Required: true, + }, + "test_attribute_two": resourceschema.BoolAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceIdentityProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testLinkedResourceIdentityProto6Value := tftypes.NewValue(testLinkedResourceIdentityProto6Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testLinkedResourceIdentityProto6DynamicValue, err := tfprotov6.NewDynamicValue(testLinkedResourceIdentityProto6Type, testLinkedResourceIdentityProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testCases := map[string]struct { fw *fwserver.InvokeActionResponse expected tfprotov6.InvokeActionEvent }{ + "linkedresource": { + fw: &fwserver.InvokeActionResponse{ + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{ + { + NewState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto6Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + expected: tfprotov6.InvokeActionEvent{ + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{ + { + NewState: &testLinkedResourceProto6DynamicValue, + NewIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + }, + }, + }, + }, + "linkedresources": { + fw: &fwserver.InvokeActionResponse{ + LinkedResources: []*fwserver.InvokeActionResponseLinkedResource{ + { + NewState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + NewIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto6Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + NewState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + }, + }, + }, + expected: tfprotov6.InvokeActionEvent{ + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{ + { + NewState: &testLinkedResourceProto6DynamicValue, + NewIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + { + NewState: &testLinkedResourceProto6DynamicValue, + }, + }, + }, + }, + }, "diagnostics": { fw: &fwserver.InvokeActionResponse{ Diagnostics: diag.Diagnostics{ @@ -62,6 +184,7 @@ func TestCompletedInvokeActionEventType(t *testing.T) { }, expected: tfprotov6.InvokeActionEvent{ Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{}, Diagnostics: []*tfprotov6.Diagnostic{ { Severity: tfprotov6.DiagnosticSeverityWarning, diff --git a/internal/toproto6/planaction.go b/internal/toproto6/planaction.go index 6411005f4..3b66b76af 100644 --- a/internal/toproto6/planaction.go +++ b/internal/toproto6/planaction.go @@ -22,7 +22,20 @@ func PlanActionResponse(ctx context.Context, fw *fwserver.PlanActionResponse) *t Deferred: ActionDeferred(fw.Deferred), } - // TODO:Actions: Here we need to set linked resource data + proto6.LinkedResources = make([]*tfprotov6.PlannedLinkedResource, len(fw.LinkedResources)) + + for i, linkedResource := range fw.LinkedResources { + plannedState, diags := State(ctx, linkedResource.PlannedState) + proto6.Diagnostics = append(proto6.Diagnostics, Diagnostics(ctx, diags)...) + + plannedIdentity, diags := ResourceIdentity(ctx, linkedResource.PlannedIdentity) + proto6.Diagnostics = append(proto6.Diagnostics, Diagnostics(ctx, diags)...) + + proto6.LinkedResources[i] = &tfprotov6.PlannedLinkedResource{ + PlannedState: plannedState, + PlannedIdentity: plannedIdentity, + } + } return proto6 } diff --git a/internal/toproto6/planaction_test.go b/internal/toproto6/planaction_test.go index 7818597fb..79cf6c357 100644 --- a/internal/toproto6/planaction_test.go +++ b/internal/toproto6/planaction_test.go @@ -9,11 +9,15 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" + "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" + resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) func TestPlanActionResponse(t *testing.T) { @@ -27,6 +31,59 @@ func TestPlanActionResponse(t *testing.T) { Reason: tfprotov6.DeferredReasonAbsentPrereq, } + testLinkedResourceProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute_one": tftypes.String, + "test_attribute_two": tftypes.Bool, + }, + } + + testLinkedResourceProto6Value := tftypes.NewValue(testLinkedResourceProto6Type, map[string]tftypes.Value{ + "test_attribute_one": tftypes.NewValue(tftypes.String, "test-value-1"), + "test_attribute_two": tftypes.NewValue(tftypes.Bool, true), + }) + + testLinkedResourceProto6DynamicValue, err := tfprotov6.NewDynamicValue(testLinkedResourceProto6Type, testLinkedResourceProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testLinkedResourceSchema := resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_attribute_one": resourceschema.StringAttribute{ + Required: true, + }, + "test_attribute_two": resourceschema.BoolAttribute{ + Required: true, + }, + }, + } + + testLinkedResourceIdentityProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_id": tftypes.String, + }, + } + + testLinkedResourceIdentityProto6Value := tftypes.NewValue(testLinkedResourceIdentityProto6Type, map[string]tftypes.Value{ + "test_id": tftypes.NewValue(tftypes.String, "id-123"), + }) + + testLinkedResourceIdentityProto6DynamicValue, err := tfprotov6.NewDynamicValue(testLinkedResourceIdentityProto6Type, testLinkedResourceIdentityProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testLinkedResourceIdentitySchema := identityschema.Schema{ + Attributes: map[string]identityschema.Attribute{ + "test_id": identityschema.StringAttribute{ + RequiredForImport: true, + }, + }, + } + testCases := map[string]struct { input *fwserver.PlanActionResponse expected *tfprotov6.PlanActionResponse @@ -36,8 +93,71 @@ func TestPlanActionResponse(t *testing.T) { expected: nil, }, "empty": { - input: &fwserver.PlanActionResponse{}, - expected: &tfprotov6.PlanActionResponse{}, + input: &fwserver.PlanActionResponse{}, + expected: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{}, + }, + }, + "linkedresource": { + input: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{ + { + PlannedState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto6Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + }, + }, + expected: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{ + { + PlannedState: &testLinkedResourceProto6DynamicValue, + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + }, + }, + }, + "linkedresources": { + input: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{ + { + PlannedState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + PlannedIdentity: &tfsdk.ResourceIdentity{ + Raw: testLinkedResourceIdentityProto6Value, + Schema: testLinkedResourceIdentitySchema, + }, + }, + { + PlannedState: &tfsdk.State{ + Raw: testLinkedResourceProto6Value, + Schema: testLinkedResourceSchema, + }, + }, + }, + }, + expected: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{ + { + PlannedState: &testLinkedResourceProto6DynamicValue, + PlannedIdentity: &tfprotov6.ResourceIdentityData{ + IdentityData: &testLinkedResourceIdentityProto6DynamicValue, + }, + }, + { + PlannedState: &testLinkedResourceProto6DynamicValue, + }, + }, + }, }, "diagnostics": { input: &fwserver.PlanActionResponse{ @@ -47,6 +167,7 @@ func TestPlanActionResponse(t *testing.T) { }, }, expected: &tfprotov6.PlanActionResponse{ + LinkedResources: []*tfprotov6.PlannedLinkedResource{}, Diagnostics: []*tfprotov6.Diagnostic{ { Severity: tfprotov6.DiagnosticSeverityWarning, @@ -66,7 +187,8 @@ func TestPlanActionResponse(t *testing.T) { Deferred: testDeferral, }, expected: &tfprotov6.PlanActionResponse{ - Deferred: testProto6Deferred, + Deferred: testProto6Deferred, + LinkedResources: []*tfprotov6.PlannedLinkedResource{}, }, }, } From a096adbcbc5b83bf2fd5dbec64f358dd2fda10ba Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 6 Aug 2025 15:53:41 -0400 Subject: [PATCH 17/18] docs --- action/doc.go | 16 +++++- action/invoke.go | 53 +++++++++++++++----- action/modify_plan.go | 45 ++++++++++++++--- action/schema/execution_order.go | 8 ++- action/schema/lifecycle_schema.go | 15 ++++-- action/schema/linked_resource.go | 81 ++++++++++++++++++++++++++----- action/schema/schema_type.go | 6 ++- 7 files changed, 189 insertions(+), 35 deletions(-) diff --git a/action/doc.go b/action/doc.go index 351e8b352..0845c3099 100644 --- a/action/doc.go +++ b/action/doc.go @@ -1,5 +1,19 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 -// TODO:Actions: Eventual package docs for actions +// Package action contains all interfaces, request types, and response +// types for an action implementation. +// +// In Terraform, an action is a concept which enables provider developers +// to offer practitioners ad-hoc side-effects to be used in their configuration. +// Depending on the type of action defined (unlinked, lifecycle, or linked), practitioners +// can trigger actions through the Terraform CLI and as part of the plan / apply lifecycle. +// Actions do not produce any data for practitioners to consume in their configurations, but +// can modify attributes of pre-defined managed resources (referred to as linked resources). +// +// The main starting point for implementations in this package is the +// [Action] type which represents an instance of an action that has its +// own configuration, plan, and invoke logic. The [Action] implementations +// are referenced by the [provider.ProviderWithActions] type Actions method, +// which enables the action practitioner usage. package action diff --git a/action/invoke.go b/action/invoke.go index ceed7ae65..476539a05 100644 --- a/action/invoke.go +++ b/action/invoke.go @@ -14,15 +14,33 @@ type InvokeRequest struct { // Config is the configuration the user supplied for the action. Config tfsdk.Config + // LinkedResources contains the data of the managed resource types that are linked to this action. + // + // - If the action schema type is Unlinked, this field will be empty. + // - If the action schema type is Lifecycle, this field will contain a single linked resource. + // - If the action schema type is Linked, this field will be one or more linked resources, which + // will be in the same order as the linked resource schemas are defined in the action schema. + // + // For Lifecycle actions, the provider may only change computed-only attributes of the linked resources. + // For Linked actions, the provider may change any attributes of the linked resources. LinkedResources []InvokeRequestLinkedResource } -// TODO:Actions: docs, change name of this struct :? +// InvokeRequestLinkedResource represents linked resource data before the action is invoked. type InvokeRequestLinkedResource struct { - Config tfsdk.Config - State tfsdk.State + // Config is the configuration the user supplied for the linked resource. + Config tfsdk.Config + + // State is the current state of the linked resource. + State tfsdk.State + + // Identity is the planned identity of the linked resource. If the linked resource does not + // support identity, this value will not be set. Identity *tfsdk.ResourceIdentity - Plan tfsdk.Plan + + // Plan is the latest planned new state for the linked resource. This could + // be the original plan, the result of the linked resource apply, or an invoke from a predecessor action. + Plan tfsdk.Plan } // InvokeResponse represents a response to an InvokeRequest. An @@ -36,20 +54,33 @@ type InvokeResponse struct { // generated. Diagnostics diag.Diagnostics + // LinkedResources contains the provider modified data of the managed resource types that are linked to this action. + // + // For Lifecycle actions, the provider may only change computed-only attributes of the linked resources. + // For Linked actions, the provider may change any attributes of the linked resources. LinkedResources []InvokeResponseLinkedResource // SendProgress will immediately send a progress update to Terraform core during action invocation. - // This function is provided by the framework and can be called multiple times while action logic is running. - // - // TODO:Actions: More documentation about when you should use this / when you shouldn't + // This function is pre-populated by the framework and can be called multiple times while action logic is running. SendProgress func(event InvokeProgressEvent) } -// TODO:Actions: docs, change name of this struct :? +// InvokeResponseLinkedResource represents linked resource data that was changed during Invoke and returned. type InvokeResponseLinkedResource struct { - State tfsdk.State - Identity *tfsdk.ResourceIdentity - RequiresReplace bool // TODO:Actions: Document that this can only be present when diagnostics exist + // State is the state of the linked resource following the Invoke operation. + // This field is pre-populated from InvokeRequest.Plan and + // should be set during the action's Invoke operation. + State tfsdk.State + + // Identity is the identity of the linked resource following the Invoke operation. + // This field is pre-populated from InvokeRequest.Identity and + // should be set during the action's Invoke operation. + Identity *tfsdk.ResourceIdentity + + // RequiresReplace indicates that the linked resource must be replaced as a result of an action invocation error. + // This field can only be set to true if diagnostics are returned in [InvokeResponse], otherwise Framework will append + // a provider implementation diagnostic to [InvokeResponse]. + RequiresReplace bool } // InvokeProgressEvent is the event returned to Terraform while an action is being invoked. diff --git a/action/modify_plan.go b/action/modify_plan.go index a5444d8bb..08e010379 100644 --- a/action/modify_plan.go +++ b/action/modify_plan.go @@ -30,6 +30,15 @@ type ModifyPlanRequest struct { // from knowing the value at request time. Config tfsdk.Config + // LinkedResources contains the data of the managed resource types that are linked to this action. + // + // - If the action schema type is Unlinked, this field will be empty. + // - If the action schema type is Lifecycle, this field will contain a single linked resource. + // - If the action schema type is Linked, this field will be one or more linked resources, which + // will be in the same order as the linked resource schemas are defined in the action schema. + // + // For Lifecycle actions, the provider may only change computed-only attributes of the linked resources. + // For Linked actions, the provider may change any attributes of the linked resources. LinkedResources []ModifyPlanRequestLinkedResource // ClientCapabilities defines optionally supported protocol features for the @@ -37,12 +46,25 @@ type ModifyPlanRequest struct { ClientCapabilities ModifyPlanClientCapabilities } -// TODO:Actions: docs, change name of this struct :? +// ModifyPlanRequestLinkedResource represents linked resource data prior to the action plan. type ModifyPlanRequestLinkedResource struct { - Config tfsdk.Config - State tfsdk.State + // Config is the configuration the user supplied for the linked resource. + // + // This configuration may contain unknown values if a user uses + // interpolation or other functionality that would prevent Terraform + // from knowing the value at request time. + Config tfsdk.Config + + // State is the current state of the linked resource. + State tfsdk.State + + // Identity is the current identity of the linked resource. If the linked resource does not + // support identity, this value will not be set. Identity *tfsdk.ResourceIdentity - Plan tfsdk.Plan + + // Plan is the latest planned new state for the linked resource. This could + // be the result of the linked resource plan or a plan from a predecessor action. + Plan tfsdk.Plan } // ModifyPlanResponse represents a response to a @@ -56,6 +78,10 @@ type ModifyPlanResponse struct { // generated. Diagnostics diag.Diagnostics + // LinkedResources contains the provider modified data of the managed resource types that are linked to this action. + // + // For Lifecycle actions, the provider may only change computed-only attributes of the linked resources. + // For Linked actions, the provider may change any attributes of the linked resources. LinkedResources []ModifyPlanResponseLinkedResource // Deferred indicates that Terraform should defer planning this @@ -69,8 +95,15 @@ type ModifyPlanResponse struct { Deferred *Deferred } -// TODO:Actions: docs, change name of this struct :? +// ModifyPlanResponseLinkedResource represents linked resource data that was planned by the action. type ModifyPlanResponseLinkedResource struct { - Plan tfsdk.Plan + // Plan is the planned new state for the linked resource. + // + // For Lifecycle actions, the provider may only change computed-only attributes of the linked resources. + // For Linked actions, the provider may change any attributes of the linked resources. + Plan tfsdk.Plan + + // Identity is the planned new identity of the resource. + // This field is pre-populated from ModifyPlanRequest.Identity. Identity *tfsdk.ResourceIdentity } diff --git a/action/schema/execution_order.go b/action/schema/execution_order.go index 4858d7589..2d02ac301 100644 --- a/action/schema/execution_order.go +++ b/action/schema/execution_order.go @@ -3,15 +3,21 @@ package schema -// TODO:Actions: docs const ( + // ExecutionOrderInvalid is used to indicate an invalid [ExecutionOrder]. + // Provider developers should not use it. ExecutionOrderInvalid ExecutionOrder = 0 + // ExecutionOrderBefore is used to indicate that the action must be invoked before it's + // linked resource's plan/apply. ExecutionOrderBefore ExecutionOrder = 1 + // ExecutionOrderAfter is used to indicate that the action must be invoked after it's + // linked resource's plan/apply. ExecutionOrderAfter ExecutionOrder = 2 ) +// ExecutionOrder is an enum that represents when an action is invoked relative to it's linked resource. type ExecutionOrder int32 func (d ExecutionOrder) String() string { diff --git a/action/schema/lifecycle_schema.go b/action/schema/lifecycle_schema.go index bfc454fb5..07673552e 100644 --- a/action/schema/lifecycle_schema.go +++ b/action/schema/lifecycle_schema.go @@ -16,12 +16,21 @@ import ( var _ SchemaType = LifecycleSchema{} // LifecycleSchema defines the structure and value types of a lifecycle action. A lifecycle action -// can cause changes to exactly resource state, defined as a linked resource. -// -// TODO:Actions: docs +// can cause changes to exactly one resource state, defined as a linked resource. type LifecycleSchema struct { + // ExecutionOrder defines when the lifecycle action must be executed in relation to the linked resource, + // either before or after the linked resource's plan/apply. ExecutionOrder ExecutionOrder + // LinkedResource represents the managed resource type that this action can make state changes to. The linked + // resource must be defined in the same provider as the action is defined. + // + // - If the managed resource is built with terraform-plugin-framework, use [LinkedResource]. + // - If the managed resource is built with terraform-plugin-sdk/v2 or the terraform-plugin-go tfprotov5 package, use [RawV5LinkedResource]. + // - If the managed resource is built with the terraform-plugin-go tfprotov6 package, use [RawV6LinkedResource]. + // + // As a lifecycle action can only have a single linked resource, this linked resource data will always be at index 0 + // in the ModifyPlan and Invoke LinkedResources slice. LinkedResource LinkedResourceType // Attributes is the mapping of underlying attribute names to attribute diff --git a/action/schema/linked_resource.go b/action/schema/linked_resource.go index c805e8376..cde67ce31 100644 --- a/action/schema/linked_resource.go +++ b/action/schema/linked_resource.go @@ -14,18 +14,43 @@ var ( _ LinkedResourceType = RawV6LinkedResource{} ) -// TODO:Actions: docs +// LinkedResourceType is the interface that a linked resource type must implement. Linked resource +// types are statically defined by framework, so this interface is not meant to be implemented +// outside of this package. +// +// LinkedResourceType implementations allow a provider to describe to Terraform the managed resource types that +// can be modified by lifecycle and linked actions. The most common implementation to use is [LinkedResource], however, +// additional implementations exist to allow providers to write actions that modify managed resources that are +// written in SDKs other than framework. [RawV5LinkedResource] can be used for protocol v5 managed resources +// (terraform-plugin-sdk/v2 or terraform-plugin-go) and [RawV6LinkedResource] can be used for protocol v6 managed +// resources (terraform-plugin-go). +// +// Regardless of which linked resource type is used in the schema, the methods for accessing and setting the data during +// the action plan and invoke are the same. type LinkedResourceType interface { + // Linked resource types are statically defined by framework, so this + // interface is not meant to be implemented outside of this package isLinkedResourceType() + // GetTypeName returns the full name of the managed resource which can have it's resource state changed by the action. GetTypeName() string + + // GetDescription returns the human-readable description of the linked resource. GetDescription() string } -type LinkedResources []LinkedResource -// TODO:Actions: docs +// LinkedResource describes to Terraform a managed resource that can be modified by a lifecycle or linked action. This +// implementation only needs the TypeName of the managed resource. +// +// The linked resource must be defined on the same framework provider server as the action. type LinkedResource struct { - TypeName string + // TypeName is the name of the managed resource which can have it's resource state changed by the action. + // The name should be prefixed with the provider shortname and an underscore. + // + // The linked resource must be defined in the same provider as the action is defined. + TypeName string + + // Description is a human-readable description of the linked resource. Description string } @@ -39,11 +64,27 @@ func (l LinkedResource) GetDescription() string { return l.Description } -// TODO:Actions: docs +// RawV5LinkedResource describes to Terraform a managed resource that can be modified by a lifecycle or linked action. This +// implementation needs the TypeName, Schema and (if supported) the resource identity schema of the managed resource. The most common +// scenario for using this linked resource type is when defining an action that modifies a resource implemented with terraform-plugin-sdk/v2. +// +// If the linked resource is already defined in framework, use [LinkedResource]. If the linked resource is implemented with +// protocol v6, use [RawV6LinkedResource]. type RawV5LinkedResource struct { - TypeName string - Description string - Schema func() *tfprotov5.Schema + // TypeName is the name of the managed resource which can have it's resource state changed by the action. + // The name should be prefixed with the provider shortname and an underscore. + // + // The linked resource must be defined in the same provider as the action is defined. + TypeName string + + // Description is a human-readable description of the linked resource. + Description string + + // Schema is a function that returns the protocol v5 schema of the linked resource. + Schema func() *tfprotov5.Schema + + // IdentitySchema is a function returns the protocol v5 identity schema of the linked resource. This field + // is only needed if the managed resource supports resource identity. IdentitySchema func() *tfprotov5.ResourceIdentitySchema } @@ -71,11 +112,27 @@ func (l RawV5LinkedResource) GetIdentitySchema() *tfprotov5.ResourceIdentitySche return l.IdentitySchema() } -// TODO:Actions: docs +// RawV6LinkedResource describes to Terraform a managed resource that can be modified by a lifecycle or linked action. This +// implementation needs the TypeName, Schema and (if supported) the resource identity schema of the managed resource. The most common +// scenario for using this linked resource type is when defining an action that modifies a resource implemented with terraform-plugin-go. +// +// If the linked resource is already defined in framework, use [LinkedResource]. If the linked resource is implemented with +// protocol v5, use [RawV5LinkedResource]. type RawV6LinkedResource struct { - TypeName string - Description string - Schema func() *tfprotov6.Schema + // TypeName is the name of the managed resource which can have it's resource state changed by the action. + // The name should be prefixed with the provider shortname and an underscore. + // + // The linked resource must be defined in the same provider as the action is defined. + TypeName string + + // Description is a human-readable description of the linked resource. + Description string + + // Schema is a function returns the protocol v6 schema of the linked resource. + Schema func() *tfprotov6.Schema + + // IdentitySchema is a function returns the protocol v6 identity schema of the linked resource. This field + // is only needed if the managed resource supports resource identity. IdentitySchema func() *tfprotov6.ResourceIdentitySchema } diff --git a/action/schema/schema_type.go b/action/schema/schema_type.go index 5b3872520..68b2be825 100644 --- a/action/schema/schema_type.go +++ b/action/schema/schema_type.go @@ -39,6 +39,10 @@ type SchemaType interface { // interface is not meant to be implemented outside of this package isActionSchemaType() - // TODO:Actions: docs + // LinkedResourceTypes returns all linked resource definitions for an action schema. + // + // - [UnlinkedSchema] will always return zero linked resource types. + // - [LifecycleSchema] will always return one linked resource type. + // - [LinkedSchema] will return one or more linked resource types. LinkedResourceTypes() []LinkedResourceType } From 19a6ddabb701a0be62275c039223f40f93259c41 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 6 Aug 2025 15:58:40 -0400 Subject: [PATCH 18/18] add copyright --- types/basetypes/terraform_type_to_framework_type.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/types/basetypes/terraform_type_to_framework_type.go b/types/basetypes/terraform_type_to_framework_type.go index e0db55c56..e3680b086 100644 --- a/types/basetypes/terraform_type_to_framework_type.go +++ b/types/basetypes/terraform_type_to_framework_type.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package basetypes import (