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 65be360fb..476539a05 100644 --- a/action/invoke.go +++ b/action/invoke.go @@ -14,7 +14,33 @@ 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 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 +} + +// InvokeRequestLinkedResource represents linked resource data before the action is invoked. +type InvokeRequestLinkedResource struct { + // 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 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 @@ -28,13 +54,33 @@ type InvokeResponse struct { // generated. Diagnostics diag.Diagnostics - // 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. + // LinkedResources contains the provider modified data of the managed resource types that are linked to this action. // - // TODO:Actions: More documentation about when you should use this / when you shouldn't + // 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 pre-populated by the framework and can be called multiple times while action logic is running. SendProgress func(event InvokeProgressEvent) +} + +// InvokeResponseLinkedResource represents linked resource data that was changed during Invoke and returned. +type InvokeResponseLinkedResource struct { + // 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 - // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented + // 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 708656f33..08e010379 100644 --- a/action/modify_plan.go +++ b/action/modify_plan.go @@ -30,13 +30,43 @@ 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 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 // PlanAction RPC, such as forward-compatible Terraform behavior changes. ClientCapabilities ModifyPlanClientCapabilities } +// ModifyPlanRequestLinkedResource represents linked resource data prior to the action plan. +type ModifyPlanRequestLinkedResource struct { + // 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 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 // ModifyPlanRequest. An instance of this response struct is supplied // as an argument to the action's ModifyPlan function, in which the provider @@ -48,7 +78,11 @@ type ModifyPlanResponse struct { // generated. Diagnostics diag.Diagnostics - // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented + // 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 // action until a follow-up apply operation. @@ -60,3 +94,16 @@ type ModifyPlanResponse struct { // to change or break without warning. It is not protected by version compatibility guarantees. Deferred *Deferred } + +// ModifyPlanResponseLinkedResource represents linked resource data that was planned by the action. +type ModifyPlanResponseLinkedResource struct { + // 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 new file mode 100644 index 000000000..2d02ac301 --- /dev/null +++ b/action/schema/execution_order.go @@ -0,0 +1,33 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +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 { + 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..07673552e --- /dev/null +++ b/action/schema/lifecycle_schema.go @@ -0,0 +1,187 @@ +// 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 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 + // 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..cde67ce31 --- /dev/null +++ b/action/schema/linked_resource.go @@ -0,0 +1,161 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +var ( + _ LinkedResourceType = LinkedResource{} + _ LinkedResourceType = RawV5LinkedResource{} + _ LinkedResourceType = RawV6LinkedResource{} +) + +// 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 +} + +// 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 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 +} + +func (l LinkedResource) isLinkedResourceType() {} + +func (l LinkedResource) GetTypeName() string { + return l.TypeName +} + +func (l LinkedResource) GetDescription() string { + return l.Description +} + +// 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 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 +} + +func (l RawV5LinkedResource) isLinkedResourceType() {} + +func (l RawV5LinkedResource) GetTypeName() string { + return l.TypeName +} + +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() +} + +// 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 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 +} + +func (l RawV6LinkedResource) isLinkedResourceType() {} + +func (l RawV6LinkedResource) GetTypeName() string { + return l.TypeName +} + +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/action/schema/schema_type.go b/action/schema/schema_type.go index f9c5d689a..68b2be825 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 @@ -38,4 +38,11 @@ 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() + + // 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 } 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/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/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/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/fromproto5/planaction.go b/internal/fromproto5/planaction.go index b01b0d410..29e405a4b 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,84 @@ 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) { + 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(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 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] + + // 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/fromproto5/planaction_test.go b/internal/fromproto5/planaction_test.go index 294792d58..c1c60cadc 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.PlanActionRequestLinkedResource{ + { + 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.PlanActionRequestLinkedResource{ + { + 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) + 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) diff --git a/internal/fromproto5/resource_schema.go b/internal/fromproto5/resource_schema.go new file mode 100644 index 000000000..96c30c900 --- /dev/null +++ b/internal/fromproto5/resource_schema.go @@ -0,0 +1,208 @@ +// 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: 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 []*tfprotov5.SchemaAttribute) (map[string]resourceschema.Attribute, error) { + attrs := make(map[string]resourceschema.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 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 []*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: + // 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/fromproto5/resource_schema_test.go b/internal/fromproto5/resource_schema_test.go new file mode 100644 index 000000000..685f87e1c --- /dev/null +++ b/internal/fromproto5/resource_schema_test.go @@ -0,0 +1,455 @@ +// 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, + 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: &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), + }, + }, + "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 "tuple", 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 { + 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/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 + } + }) + } +} 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/fwserver/server_planaction.go b/internal/fwserver/server_planaction.go index 4ed956e20..41dbc4a20 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,26 @@ type PlanActionRequest struct { ActionSchema fwschema.Schema Action action.Action Config *tfsdk.Config + LinkedResources []*PlanActionRequestLinkedResource +} + +type PlanActionRequestLinkedResource 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 + Deferred *action.Deferred + Diagnostics diag.Diagnostics + LinkedResources []*PlanActionResponseLinkedResource +} + +type PlanActionResponseLinkedResource struct { + PlannedState *tfsdk.State + PlannedIdentity *tfsdk.ResourceIdentity } // PlanAction implements the framework server PlanAction RPC. @@ -34,8 +49,20 @@ 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. + // Copy over planned state and identity to the response for each linked resource as a default plan + resp.LinkedResources = make([]*PlanActionResponseLinkedResource, len(req.LinkedResources)) + for i, lr := range req.LinkedResources { + resp.LinkedResources[i] = &PlanActionResponseLinkedResource{ + 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.", @@ -82,10 +109,34 @@ func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *P 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 +145,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 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.", + ) + return + } + + for i, newLinkedResource := range modifyPlanResp.LinkedResources { + resp.LinkedResources[i].PlannedState = planToState(newLinkedResource.Plan) + resp.LinkedResources[i].PlannedIdentity = newLinkedResource.Identity + } } } diff --git a/internal/fwserver/server_planaction_test.go b/internal/fwserver/server_planaction_test.go index d922eb844..feba37608 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, @@ -70,7 +109,9 @@ func TestServerPlanAction(t *testing.T) { ActionSchema: testUnlinkedSchema, Action: &testprovider.Action{}, }, - expectedResponse: &fwserver.PlanActionResponse{}, + expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, + }, }, "request-client-capabilities-deferral-allowed": { server: &fwserver.Server{ @@ -95,7 +136,9 @@ func TestServerPlanAction(t *testing.T) { }, }, }, - expectedResponse: &fwserver.PlanActionResponse{}, + expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, + }, }, "request-config": { server: &fwserver.Server{ @@ -118,7 +161,134 @@ func TestServerPlanAction(t *testing.T) { }, }, }, - expectedResponse: &fwserver.PlanActionResponse{}, + expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, + }, + }, + "request-linkedresources": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.PlanActionRequestLinkedResource{ + { + 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.PlanActionResponseLinkedResource{ + { + 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": { server: &fwserver.Server{ @@ -155,7 +325,9 @@ func TestServerPlanAction(t *testing.T) { }, }, }, - expectedResponse: &fwserver.PlanActionResponse{}, + expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, + }, }, "response-deferral-automatic": { server: &fwserver.Server{ @@ -182,7 +354,8 @@ func TestServerPlanAction(t *testing.T) { ClientCapabilities: testDeferralAllowed, }, expectedResponse: &fwserver.PlanActionResponse{ - Deferred: &action.Deferred{Reason: action.DeferredReasonProviderConfigUnknown}, + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, + Deferred: &action.Deferred{Reason: action.DeferredReasonProviderConfigUnknown}, }, }, "response-deferral-manual": { @@ -210,7 +383,8 @@ func TestServerPlanAction(t *testing.T) { ClientCapabilities: testDeferralAllowed, }, expectedResponse: &fwserver.PlanActionResponse{ - Deferred: &action.Deferred{Reason: action.DeferredReasonAbsentPrereq}, + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, + Deferred: &action.Deferred{Reason: action.DeferredReasonAbsentPrereq}, }, }, "response-diagnostics": { @@ -228,6 +402,7 @@ func TestServerPlanAction(t *testing.T) { }, }, expectedResponse: &fwserver.PlanActionResponse{ + LinkedResources: []*fwserver.PlanActionResponseLinkedResource{}, Diagnostics: diag.Diagnostics{ diag.NewWarningDiagnostic( "warning summary", @@ -240,6 +415,268 @@ func TestServerPlanAction(t *testing.T) { }, }, }, + "response-linkedresources": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + ActionSchema: testEmptyActionSchema, + LinkedResources: []*fwserver.PlanActionRequestLinkedResource{ + { + 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.PlanActionResponseLinkedResource{ + { + 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.PlanActionRequestLinkedResource{ + { + 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.PlanActionResponseLinkedResource{ + { + 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.PlanActionRequestLinkedResource{ + { + 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.PlanActionResponseLinkedResource{ + { + 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/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 216a067b3..7d1f42773 100644 --- a/internal/proto5server/server_invokeaction.go +++ b/internal/proto5server/server_invokeaction.go @@ -52,7 +52,15 @@ func (s *Server) InvokeAction(ctx context.Context, proto5Req *tfprotov5.InvokeAc return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) } - fwReq, diags := fromproto5.InvokeActionRequest(ctx, proto5Req, action, actionSchema) + 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) 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/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 39a31ff4b..fded848fc 100644 --- a/internal/proto5server/server_planaction.go +++ b/internal/proto5server/server_planaction.go @@ -36,7 +36,15 @@ 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, 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) fwResp.Diagnostics.Append(diags...) 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.", }, }, }, 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/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/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/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/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, diff --git a/internal/toproto5/planaction.go b/internal/toproto5/planaction.go index 06f12faaf..76f6492a8 100644 --- a/internal/toproto5/planaction.go +++ b/internal/toproto5/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 + 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 } diff --git a/internal/toproto5/planaction_test.go b/internal/toproto5/planaction_test.go index 44aca43ae..0290eed2d 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.PlanActionResponseLinkedResource{ + { + 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.PlanActionResponseLinkedResource{ + { + 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{}, }, }, } 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/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/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/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{}, }, }, } 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/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/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 } 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..e3680b086 --- /dev/null +++ b/types/basetypes/terraform_type_to_framework_type.go @@ -0,0 +1,95 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package basetypes + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// 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) { + 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) +} 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) + } + }) + } +}