diff --git a/action/action.go b/action/action.go index f172651f9..fdec8e15b 100644 --- a/action/action.go +++ b/action/action.go @@ -12,5 +12,40 @@ type Action interface { // Metadata should return the full name of the action, such as examplecloud_do_thing. Metadata(context.Context, MetadataRequest, *MetadataResponse) - // TODO:Actions: Eventual landing place for all required methods to implement for an action + // Invoke is called to run the logic of the action and update linked resources if applicable. + // Config, linked resource planned state, and linked resource prior state values should + // be read from the InvokeRequest and new linked resource state values set on the InvokeResponse. + // + // The [InvokeResponse.SendProgress] function can be called in the Invoke method to immediately + // report progress events related to the invocation of the action to Terraform. + Invoke(context.Context, InvokeRequest, *InvokeResponse) +} + +// ActionWithConfigure is an interface type that extends Action to +// include a method which the framework will automatically call so provider +// developers have the opportunity to setup any necessary provider-level data +// or clients in the Action type. +type ActionWithConfigure interface { + Action + + // Configure enables provider-level data or clients to be set in the + // provider-defined Action type. + Configure(context.Context, ConfigureRequest, *ConfigureResponse) +} + +// ActionWithModifyPlan represents an action with a ModifyPlan function. +type ActionWithModifyPlan interface { + Action + + // ModifyPlan is called when the provider has an opportunity to modify + // the plan for an action: once during the plan phase, and once + // during the apply phase with any unknown values from configuration + // filled in with their final values. + // + // Actions do not have computed attributes that can be modified during the plan, + // but linked and lifecycle actions can modify the plan of linked resources. + // + // All action schema types can use the plan as an opportunity to raise early + // diagnostics to practitioners, such as validation errors. + ModifyPlan(context.Context, ModifyPlanRequest, *ModifyPlanResponse) } diff --git a/action/configure.go b/action/configure.go new file mode 100644 index 000000000..f9d8d2440 --- /dev/null +++ b/action/configure.go @@ -0,0 +1,32 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package action + +import "github.com/hashicorp/terraform-plugin-framework/diag" + +// ConfigureRequest represents a request for the provider to configure an +// action, i.e., set provider-level data or clients. An instance of this +// request struct is supplied as an argument to the Action type Configure +// method. +type ConfigureRequest struct { + // ProviderData is the data set in the + // [provider.ConfigureResponse.ActionData] field. This data is + // provider-specifc and therefore can contain any necessary remote system + // clients, custom provider data, or anything else pertinent to the + // functionality of the Action. + // + // This data is only set after the ConfigureProvider RPC has been called + // by Terraform. + ProviderData any +} + +// ConfigureResponse represents a response to a ConfigureRequest. An +// instance of this response struct is supplied as an argument to the +// Action type Configure method. +type ConfigureResponse struct { + // Diagnostics report errors or warnings related to configuring of the + // Datasource. An empty slice indicates a successful operation with no + // warnings or errors generated. + Diagnostics diag.Diagnostics +} diff --git a/action/deferred.go b/action/deferred.go new file mode 100644 index 000000000..fb852d88c --- /dev/null +++ b/action/deferred.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package action + +const ( + // DeferredReasonUnknown is used to indicate an invalid `DeferredReason`. + // Provider developers should not use it. + DeferredReasonUnknown DeferredReason = 0 + + // DeferredReasonActionConfigUnknown is used to indicate that the action configuration + // is partially unknown and the real values need to be known before the change can be planned. + DeferredReasonActionConfigUnknown DeferredReason = 1 + + // DeferredReasonProviderConfigUnknown is used to indicate that the provider configuration + // is partially unknown and the real values need to be known before the change can be planned. + DeferredReasonProviderConfigUnknown DeferredReason = 2 + + // DeferredReasonAbsentPrereq is used to indicate that a hard dependency has not been satisfied. + DeferredReasonAbsentPrereq DeferredReason = 3 +) + +// Deferred is used to indicate to Terraform that a change needs to be deferred for a reason. +// +// NOTE: This functionality is related to deferred action support, which is currently experimental and is subject +// to change or break without warning. It is not protected by version compatibility guarantees. +type Deferred struct { + // Reason is the reason for deferring the change. + Reason DeferredReason +} + +// DeferredReason represents different reasons for deferring a change. +// +// NOTE: This functionality is related to deferred action support, which is currently experimental and is subject +// to change or break without warning. It is not protected by version compatibility guarantees. +type DeferredReason int32 + +func (d DeferredReason) String() string { + switch d { + case 0: + return "Unknown" + case 1: + return "Action Config Unknown" + case 2: + return "Provider Config Unknown" + case 3: + return "Absent Prerequisite" + } + return "Unknown" +} diff --git a/action/invoke.go b/action/invoke.go new file mode 100644 index 000000000..65be360fb --- /dev/null +++ b/action/invoke.go @@ -0,0 +1,45 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package action + +import ( + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// InvokeRequest represents a request for the provider to invoke the action and update +// the requested action's linked resources. +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 +} + +// InvokeResponse represents a response to an InvokeRequest. An +// instance of this response struct is supplied as +// an argument to the action's Invoke function, in which the provider +// should set values on the InvokeResponse as appropriate. +type InvokeResponse struct { + // Diagnostics report errors or warnings related to invoking the action or updating + // the state of the requested action's linked resources. Returning an empty slice + // indicates a successful invocation with no warnings or errors + // 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. + // + // TODO:Actions: More documentation about when you should use this / when you shouldn't + SendProgress func(event InvokeProgressEvent) + + // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented +} + +// InvokeProgressEvent is the event returned to Terraform while an action is being invoked. +type InvokeProgressEvent struct { + // Message is the string that will be presented to the practitioner either via the console + // or an external system like HCP Terraform. + Message string +} diff --git a/action/modify_plan.go b/action/modify_plan.go new file mode 100644 index 000000000..708656f33 --- /dev/null +++ b/action/modify_plan.go @@ -0,0 +1,62 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package action + +import ( + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +// ModifyPlanClientCapabilities allows Terraform to publish information +// regarding optionally supported protocol features for the PlanAction RPC, +// such as forward-compatible Terraform behavior changes. +type ModifyPlanClientCapabilities struct { + // DeferralAllowed indicates whether the Terraform client initiating + // the request allows a deferral response. + // + // NOTE: This functionality is related to deferred action support, which is currently experimental and is subject + // to change or break without warning. It is not protected by version compatibility guarantees. + DeferralAllowed bool +} + +// ModifyPlanRequest represents a request for the provider to modify the +// planned new state that Terraform has generated for any linked resources. +type ModifyPlanRequest struct { + // Config is the configuration the user supplied for the action. + // + // This configuration may contain unknown values if a user uses + // interpolation or other functionality that would prevent Terraform + // from knowing the value at request time. + Config tfsdk.Config + + // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented + + // ClientCapabilities defines optionally supported protocol features for the + // PlanAction RPC, such as forward-compatible Terraform behavior changes. + ClientCapabilities ModifyPlanClientCapabilities +} + +// 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 +// should modify the Plan of any linked resources as appropriate. +type ModifyPlanResponse struct { + // Diagnostics report errors or warnings related to determining the + // planned state of the requested action's linked resources. Returning an empty slice + // indicates a successful plan modification with no warnings or errors + // generated. + Diagnostics diag.Diagnostics + + // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented + + // Deferred indicates that Terraform should defer planning this + // action until a follow-up apply operation. + // + // This field can only be set if + // `(action.ModifyPlanRequest).ClientCapabilities.DeferralAllowed` is true. + // + // NOTE: This functionality is related to deferred action support, which is currently experimental and is subject + // to change or break without warning. It is not protected by version compatibility guarantees. + Deferred *Deferred +} diff --git a/internal/fromproto5/client_capabilities.go b/internal/fromproto5/client_capabilities.go index 737354888..89efd44f5 100644 --- a/internal/fromproto5/client_capabilities.go +++ b/internal/fromproto5/client_capabilities.go @@ -6,6 +6,7 @@ package fromproto5 import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -102,3 +103,16 @@ func ValidateResourceTypeConfigClientCapabilities(in *tfprotov5.ValidateResource WriteOnlyAttributesAllowed: in.WriteOnlyAttributesAllowed, } } + +func ModifyPlanActionClientCapabilities(in *tfprotov5.PlanActionClientCapabilities) action.ModifyPlanClientCapabilities { + if in == nil { + // Client did not indicate any supported capabilities + return action.ModifyPlanClientCapabilities{ + DeferralAllowed: false, + } + } + + return action.ModifyPlanClientCapabilities{ + DeferralAllowed: in.DeferralAllowed, + } +} diff --git a/internal/fromproto5/invokeaction.go b/internal/fromproto5/invokeaction.go index 6290147d3..85690b707 100644 --- a/internal/fromproto5/invokeaction.go +++ b/internal/fromproto5/invokeaction.go @@ -37,6 +37,7 @@ func InvokeActionRequest(ctx context.Context, proto5 *tfprotov5.InvokeActionRequ } fw := &fwserver.InvokeActionRequest{ + Action: reqAction, ActionSchema: actionSchema, } diff --git a/internal/fromproto5/invokeaction_test.go b/internal/fromproto5/invokeaction_test.go index c5b20a804..6f74fcf83 100644 --- a/internal/fromproto5/invokeaction_test.go +++ b/internal/fromproto5/invokeaction_test.go @@ -3,4 +3,118 @@ package fromproto5_test -// TODO:Actions: Add unit tests once this mapping logic is complete +import ( + "context" + "testing" + + "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/action/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +func TestInvokeActionRequest(t *testing.T) { + t.Parallel() + + testProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute": tftypes.String, + }, + } + + testProto5Value := tftypes.NewValue(testProto5Type, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }) + + testProto5DynamicValue, err := tfprotov5.NewDynamicValue(testProto5Type, testProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testUnlinkedSchema := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test_attribute": schema.StringAttribute{ + Required: true, + }, + }, + } + + testCases := map[string]struct { + input *tfprotov5.InvokeActionRequest + actionSchema fwschema.Schema + actionImpl action.Action + providerMetaSchema fwschema.Schema + expected *fwserver.InvokeActionRequest + expectedDiagnostics diag.Diagnostics + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &tfprotov5.InvokeActionRequest{}, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Action Schema", + "An unexpected error was encountered when handling the request. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Missing schema.", + ), + }, + }, + "config-missing-schema": { + input: &tfprotov5.InvokeActionRequest{ + Config: &testProto5DynamicValue, + }, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Action Schema", + "An unexpected error was encountered when handling the request. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Missing schema.", + ), + }, + }, + "config": { + input: &tfprotov5.InvokeActionRequest{ + Config: &testProto5DynamicValue, + }, + actionSchema: testUnlinkedSchema, + expected: &fwserver.InvokeActionRequest{ + Config: &tfsdk.Config{ + Raw: testProto5Value, + Schema: testUnlinkedSchema, + }, + ActionSchema: testUnlinkedSchema, + }, + }, + } + + 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) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/internal/fromproto5/planaction.go b/internal/fromproto5/planaction.go index bb71e5815..b01b0d410 100644 --- a/internal/fromproto5/planaction.go +++ b/internal/fromproto5/planaction.go @@ -37,7 +37,9 @@ func PlanActionRequest(ctx context.Context, proto5 *tfprotov5.PlanActionRequest, } fw := &fwserver.PlanActionRequest{ - ActionSchema: actionSchema, + Action: reqAction, + ActionSchema: actionSchema, + ClientCapabilities: ModifyPlanActionClientCapabilities(proto5.ClientCapabilities), } config, configDiags := Config(ctx, proto5.Config, actionSchema) @@ -46,7 +48,7 @@ func PlanActionRequest(ctx context.Context, proto5 *tfprotov5.PlanActionRequest, fw.Config = config - // TODO:Actions: Here we need to retrieve client capabilities and linked resource data + // TODO:Actions: Here we need to retrieve linked resource data return fw, diags } diff --git a/internal/fromproto5/planaction_test.go b/internal/fromproto5/planaction_test.go index c5b20a804..294792d58 100644 --- a/internal/fromproto5/planaction_test.go +++ b/internal/fromproto5/planaction_test.go @@ -3,4 +3,142 @@ package fromproto5_test -// TODO:Actions: Add unit tests once this mapping logic is complete +import ( + "context" + "testing" + + "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/action/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +func TestPlanActionRequest(t *testing.T) { + t.Parallel() + + testProto5Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute": tftypes.String, + }, + } + + testProto5Value := tftypes.NewValue(testProto5Type, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }) + + testProto5DynamicValue, err := tfprotov5.NewDynamicValue(testProto5Type, testProto5Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov5.NewDynamicValue(): %s", err) + } + + testUnlinkedSchema := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test_attribute": schema.StringAttribute{ + Required: true, + }, + }, + } + + testCases := map[string]struct { + input *tfprotov5.PlanActionRequest + actionSchema fwschema.Schema + actionImpl action.Action + providerMetaSchema fwschema.Schema + expected *fwserver.PlanActionRequest + expectedDiagnostics diag.Diagnostics + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &tfprotov5.PlanActionRequest{}, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Action Schema", + "An unexpected error was encountered when handling the request. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Missing schema.", + ), + }, + }, + "config-missing-schema": { + input: &tfprotov5.PlanActionRequest{ + Config: &testProto5DynamicValue, + }, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Action Schema", + "An unexpected error was encountered when handling the request. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Missing schema.", + ), + }, + }, + "config": { + input: &tfprotov5.PlanActionRequest{ + Config: &testProto5DynamicValue, + }, + actionSchema: testUnlinkedSchema, + expected: &fwserver.PlanActionRequest{ + Config: &tfsdk.Config{ + Raw: testProto5Value, + Schema: testUnlinkedSchema, + }, + ActionSchema: testUnlinkedSchema, + }, + }, + "client-capabilities": { + input: &tfprotov5.PlanActionRequest{ + ClientCapabilities: &tfprotov5.PlanActionClientCapabilities{ + DeferralAllowed: true, + }, + }, + actionSchema: testUnlinkedSchema, + expected: &fwserver.PlanActionRequest{ + ActionSchema: testUnlinkedSchema, + ClientCapabilities: action.ModifyPlanClientCapabilities{ + DeferralAllowed: true, + }, + }, + }, + "client-capabilities-unset": { + input: &tfprotov5.PlanActionRequest{}, + actionSchema: testUnlinkedSchema, + expected: &fwserver.PlanActionRequest{ + ActionSchema: testUnlinkedSchema, + ClientCapabilities: action.ModifyPlanClientCapabilities{ + DeferralAllowed: false, + }, + }, + }, + } + + 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) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/internal/fromproto6/client_capabilities.go b/internal/fromproto6/client_capabilities.go index d22d81623..337b8227c 100644 --- a/internal/fromproto6/client_capabilities.go +++ b/internal/fromproto6/client_capabilities.go @@ -6,6 +6,7 @@ package fromproto6 import ( "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -102,3 +103,16 @@ func ValidateResourceConfigClientCapabilities(in *tfprotov6.ValidateResourceConf WriteOnlyAttributesAllowed: in.WriteOnlyAttributesAllowed, } } + +func ModifyPlanActionClientCapabilities(in *tfprotov6.PlanActionClientCapabilities) action.ModifyPlanClientCapabilities { + if in == nil { + // Client did not indicate any supported capabilities + return action.ModifyPlanClientCapabilities{ + DeferralAllowed: false, + } + } + + return action.ModifyPlanClientCapabilities{ + DeferralAllowed: in.DeferralAllowed, + } +} diff --git a/internal/fromproto6/invokeaction.go b/internal/fromproto6/invokeaction.go index 270a2bacb..04ca704b4 100644 --- a/internal/fromproto6/invokeaction.go +++ b/internal/fromproto6/invokeaction.go @@ -37,6 +37,7 @@ func InvokeActionRequest(ctx context.Context, proto6 *tfprotov6.InvokeActionRequ } fw := &fwserver.InvokeActionRequest{ + Action: reqAction, ActionSchema: actionSchema, } diff --git a/internal/fromproto6/invokeaction_test.go b/internal/fromproto6/invokeaction_test.go index 5c9fa3702..9772d420e 100644 --- a/internal/fromproto6/invokeaction_test.go +++ b/internal/fromproto6/invokeaction_test.go @@ -3,4 +3,118 @@ package fromproto6_test -// TODO:Actions: Add unit tests once this mapping logic is complete +import ( + "context" + "testing" + + "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/action/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto6" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +func TestInvokeActionRequest(t *testing.T) { + t.Parallel() + + testProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute": tftypes.String, + }, + } + + testProto6Value := tftypes.NewValue(testProto6Type, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }) + + testProto6DynamicValue, err := tfprotov6.NewDynamicValue(testProto6Type, testProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testUnlinkedSchema := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test_attribute": schema.StringAttribute{ + Required: true, + }, + }, + } + + testCases := map[string]struct { + input *tfprotov6.InvokeActionRequest + actionSchema fwschema.Schema + actionImpl action.Action + providerMetaSchema fwschema.Schema + expected *fwserver.InvokeActionRequest + expectedDiagnostics diag.Diagnostics + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &tfprotov6.InvokeActionRequest{}, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Action Schema", + "An unexpected error was encountered when handling the request. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Missing schema.", + ), + }, + }, + "config-missing-schema": { + input: &tfprotov6.InvokeActionRequest{ + Config: &testProto6DynamicValue, + }, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Action Schema", + "An unexpected error was encountered when handling the request. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Missing schema.", + ), + }, + }, + "config": { + input: &tfprotov6.InvokeActionRequest{ + Config: &testProto6DynamicValue, + }, + actionSchema: testUnlinkedSchema, + expected: &fwserver.InvokeActionRequest{ + Config: &tfsdk.Config{ + Raw: testProto6Value, + Schema: testUnlinkedSchema, + }, + ActionSchema: testUnlinkedSchema, + }, + }, + } + + 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) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/internal/fromproto6/planaction.go b/internal/fromproto6/planaction.go index 7715037c9..838698a87 100644 --- a/internal/fromproto6/planaction.go +++ b/internal/fromproto6/planaction.go @@ -37,7 +37,9 @@ func PlanActionRequest(ctx context.Context, proto6 *tfprotov6.PlanActionRequest, } fw := &fwserver.PlanActionRequest{ - ActionSchema: actionSchema, + Action: reqAction, + ActionSchema: actionSchema, + ClientCapabilities: ModifyPlanActionClientCapabilities(proto6.ClientCapabilities), } config, configDiags := Config(ctx, proto6.Config, actionSchema) @@ -46,7 +48,7 @@ func PlanActionRequest(ctx context.Context, proto6 *tfprotov6.PlanActionRequest, fw.Config = config - // TODO:Actions: Here we need to retrieve client capabilities and linked resource data + // TODO:Actions: Here we need to retrieve linked resource data return fw, diags } diff --git a/internal/fromproto6/planaction_test.go b/internal/fromproto6/planaction_test.go index 5c9fa3702..6bc0fa7fe 100644 --- a/internal/fromproto6/planaction_test.go +++ b/internal/fromproto6/planaction_test.go @@ -3,4 +3,142 @@ package fromproto6_test -// TODO:Actions: Add unit tests once this mapping logic is complete +import ( + "context" + "testing" + + "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/action/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto6" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" +) + +func TestPlanActionRequest(t *testing.T) { + t.Parallel() + + testProto6Type := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_attribute": tftypes.String, + }, + } + + testProto6Value := tftypes.NewValue(testProto6Type, map[string]tftypes.Value{ + "test_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }) + + testProto6DynamicValue, err := tfprotov6.NewDynamicValue(testProto6Type, testProto6Value) + + if err != nil { + t.Fatalf("unexpected error calling tfprotov6.NewDynamicValue(): %s", err) + } + + testUnlinkedSchema := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test_attribute": schema.StringAttribute{ + Required: true, + }, + }, + } + + testCases := map[string]struct { + input *tfprotov6.PlanActionRequest + actionSchema fwschema.Schema + actionImpl action.Action + providerMetaSchema fwschema.Schema + expected *fwserver.PlanActionRequest + expectedDiagnostics diag.Diagnostics + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &tfprotov6.PlanActionRequest{}, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Action Schema", + "An unexpected error was encountered when handling the request. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Missing schema.", + ), + }, + }, + "config-missing-schema": { + input: &tfprotov6.PlanActionRequest{ + Config: &testProto6DynamicValue, + }, + expected: nil, + expectedDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Missing Action Schema", + "An unexpected error was encountered when handling the request. "+ + "This is always an issue in terraform-plugin-framework used to implement the provider and should be reported to the provider developers.\n\n"+ + "Please report this to the provider developer:\n\n"+ + "Missing schema.", + ), + }, + }, + "config": { + input: &tfprotov6.PlanActionRequest{ + Config: &testProto6DynamicValue, + }, + actionSchema: testUnlinkedSchema, + expected: &fwserver.PlanActionRequest{ + Config: &tfsdk.Config{ + Raw: testProto6Value, + Schema: testUnlinkedSchema, + }, + ActionSchema: testUnlinkedSchema, + }, + }, + "client-capabilities": { + input: &tfprotov6.PlanActionRequest{ + ClientCapabilities: &tfprotov6.PlanActionClientCapabilities{ + DeferralAllowed: true, + }, + }, + actionSchema: testUnlinkedSchema, + expected: &fwserver.PlanActionRequest{ + ActionSchema: testUnlinkedSchema, + ClientCapabilities: action.ModifyPlanClientCapabilities{ + DeferralAllowed: true, + }, + }, + }, + "client-capabilities-unset": { + input: &tfprotov6.PlanActionRequest{}, + actionSchema: testUnlinkedSchema, + expected: &fwserver.PlanActionRequest{ + ActionSchema: testUnlinkedSchema, + ClientCapabilities: action.ModifyPlanClientCapabilities{ + DeferralAllowed: false, + }, + }, + }, + } + + 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) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + + if diff := cmp.Diff(diags, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +} diff --git a/internal/fwserver/server.go b/internal/fwserver/server.go index 22e2c2eb2..ba7c74823 100644 --- a/internal/fwserver/server.go +++ b/internal/fwserver/server.go @@ -42,6 +42,11 @@ type Server struct { // to [ephemeral.ConfigureRequest.ProviderData]. EphemeralResourceConfigureData any + // ActionConfigureData is the + // [provider.ConfigureResponse.ActionData] field value which is passed + // to [action.ConfigureRequest.ProviderData]. + ActionConfigureData any + // actionSchemas is the cached Action Schemas for RPCs that need to // convert configuration data from the protocol. If not found, it will be // fetched from the Action.Schema() method. diff --git a/internal/fwserver/server_configureprovider.go b/internal/fwserver/server_configureprovider.go index 0b1807bce..d11c022fc 100644 --- a/internal/fwserver/server_configureprovider.go +++ b/internal/fwserver/server_configureprovider.go @@ -38,4 +38,5 @@ func (s *Server) ConfigureProvider(ctx context.Context, req *provider.ConfigureR s.DataSourceConfigureData = resp.DataSourceData s.ResourceConfigureData = resp.ResourceData s.EphemeralResourceConfigureData = resp.EphemeralResourceData + s.ActionConfigureData = resp.ActionData } diff --git a/internal/fwserver/server_configureprovider_test.go b/internal/fwserver/server_configureprovider_test.go index 96d5ac63a..83741e8cc 100644 --- a/internal/fwserver/server_configureprovider_test.go +++ b/internal/fwserver/server_configureprovider_test.go @@ -192,6 +192,20 @@ func TestServerConfigureProvider(t *testing.T) { EphemeralResourceData: "test-provider-configure-value", }, }, + "response-actiondata": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{ + SchemaMethod: func(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {}, + ConfigureMethod: func(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + resp.ActionData = "test-provider-configure-value" + }, + }, + }, + request: &provider.ConfigureRequest{}, + expectedResponse: &provider.ConfigureResponse{ + ActionData: "test-provider-configure-value", + }, + }, "response-invalid-deferral-diagnostic": { server: &fwserver.Server{ Provider: &testprovider.Provider{ diff --git a/internal/fwserver/server_getmetadata_test.go b/internal/fwserver/server_getmetadata_test.go index 798a1a77a..96aebfc22 100644 --- a/internal/fwserver/server_getmetadata_test.go +++ b/internal/fwserver/server_getmetadata_test.go @@ -1024,6 +1024,10 @@ func TestServerGetMetadata(t *testing.T) { testCase.server.GetMetadata(context.Background(), testCase.request, response) // Prevent false positives with random map access in testing + sort.Slice(response.Actions, func(i int, j int) bool { + return response.Actions[i].TypeName < response.Actions[j].TypeName + }) + sort.Slice(response.DataSources, func(i int, j int) bool { return response.DataSources[i].TypeName < response.DataSources[j].TypeName }) diff --git a/internal/fwserver/server_invokeaction.go b/internal/fwserver/server_invokeaction.go index 200a5b811..153ac6f06 100644 --- a/internal/fwserver/server_invokeaction.go +++ b/internal/fwserver/server_invokeaction.go @@ -6,27 +6,82 @@ package fwserver import ( "context" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" ) // InvokeActionRequest is the framework server request for the InvokeAction RPC. type InvokeActionRequest struct { + Action action.Action ActionSchema fwschema.Schema Config *tfsdk.Config } // InvokeActionEventsStream is the framework server stream for the InvokeAction RPC. type InvokeActionResponse struct { - Diagnostics diag.Diagnostics + // 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 +} + +type InvokeProgressEvent struct { + Message string +} + +// SendProgress is injected into the action.InvokeResponse for use by the provider developer +func (r *InvokeActionResponse) SendProgress(event action.InvokeProgressEvent) { + r.ProgressEvents <- InvokeProgressEvent{ + Message: event.Message, + } } // InvokeAction implements the framework server InvokeAction RPC. func (s *Server) InvokeAction(ctx context.Context, req *InvokeActionRequest, resp *InvokeActionResponse) { - // TODO:Actions: Implementation coming soon... - resp.Diagnostics.AddError( - "InvokeAction Not Implemented", - "InvokeAction has not yet been implemented in terraform-plugin-framework.", - ) + if req == nil { + return + } + + if actionWithConfigure, ok := req.Action.(action.ActionWithConfigure); ok { + logging.FrameworkTrace(ctx, "Action implements ActionWithConfigure") + + configureReq := action.ConfigureRequest{ + ProviderData: s.ActionConfigureData, + } + configureResp := action.ConfigureResponse{} + + logging.FrameworkTrace(ctx, "Calling provider defined Action Configure") + actionWithConfigure.Configure(ctx, configureReq, &configureResp) + logging.FrameworkTrace(ctx, "Called provider defined Action Configure") + + resp.Diagnostics.Append(configureResp.Diagnostics...) + + if resp.Diagnostics.HasError() { + return + } + } + + if req.Config == nil { + req.Config = &tfsdk.Config{ + Raw: tftypes.NewValue(req.ActionSchema.Type().TerraformType(ctx), nil), + Schema: req.ActionSchema, + } + } + + invokeReq := action.InvokeRequest{ + Config: *req.Config, + } + invokeResp := action.InvokeResponse{ + SendProgress: resp.SendProgress, + } + + logging.FrameworkTrace(ctx, "Calling provider defined Action Invoke") + req.Action.Invoke(ctx, invokeReq, &invokeResp) + logging.FrameworkTrace(ctx, "Called provider defined Action Invoke") + + resp.Diagnostics = invokeResp.Diagnostics } diff --git a/internal/fwserver/server_invokeaction_test.go b/internal/fwserver/server_invokeaction_test.go index 5879cddb3..ece66c657 100644 --- a/internal/fwserver/server_invokeaction_test.go +++ b/internal/fwserver/server_invokeaction_test.go @@ -3,4 +3,183 @@ package fwserver_test -// TODO:Actions: Add unit tests once InvokeAction is implemented +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" + "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/provider" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerInvokeAction(t *testing.T) { + t.Parallel() + + testType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_required": tftypes.String, + }, + } + + testConfigValue := tftypes.NewValue(testType, map[string]tftypes.Value{ + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }) + + testUnlinkedSchema := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test_required": schema.StringAttribute{ + Required: true, + }, + }, + } + + testUnlinkedConfig := &tfsdk.Config{ + Raw: testConfigValue, + Schema: testUnlinkedSchema, + } + + testCases := map[string]struct { + server *fwserver.Server + request *fwserver.InvokeActionRequest + expectedResponse *fwserver.InvokeActionResponse + configureProviderReq *provider.ConfigureRequest + }{ + "nil": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + expectedResponse: &fwserver.InvokeActionResponse{}, + }, + "unlinked-nil-config": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.InvokeActionRequest{ + ActionSchema: testUnlinkedSchema, + Action: &testprovider.Action{ + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + if !req.Config.Raw.IsNull() { + resp.Diagnostics.AddError("Unexpected Config in action Invoke", "Expected Config to be null") + } + }, + }, + }, + expectedResponse: &fwserver.InvokeActionResponse{}, + }, + "request-config": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.InvokeActionRequest{ + Config: testUnlinkedConfig, + ActionSchema: testUnlinkedSchema, + Action: &testprovider.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()) + } + }, + }, + }, + expectedResponse: &fwserver.InvokeActionResponse{}, + }, + "action-configure-data": { + server: &fwserver.Server{ + ActionConfigureData: "test-provider-configure-value", + Provider: &testprovider.Provider{}, + }, + request: &fwserver.InvokeActionRequest{ + Config: testUnlinkedConfig, + ActionSchema: testUnlinkedSchema, + Action: &testprovider.ActionWithConfigure{ + ConfigureMethod: func(ctx context.Context, req action.ConfigureRequest, resp *action.ConfigureResponse) { + providerData, ok := req.ProviderData.(string) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected ConfigureRequest.ProviderData", + fmt.Sprintf("Expected string, got: %T", req.ProviderData), + ) + return + } + + if providerData != "test-provider-configure-value" { + resp.Diagnostics.AddError( + "Unexpected ConfigureRequest.ProviderData", + fmt.Sprintf("Expected test-provider-configure-value, got: %q", providerData), + ) + } + }, + Action: &testprovider.Action{ + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + // In practice, the Configure method would save the + // provider data to the Action implementation and + // use it here. The fact that Configure is able to + // read the data proves this can work. + }, + }, + }, + }, + expectedResponse: &fwserver.InvokeActionResponse{}, + }, + "response-diagnostics": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.InvokeActionRequest{ + Config: testUnlinkedConfig, + ActionSchema: testUnlinkedSchema, + Action: &testprovider.Action{ + InvokeMethod: func(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + resp.Diagnostics.AddWarning("warning summary", "warning detail") + resp.Diagnostics.AddError("error summary", "error detail") + }, + }, + }, + expectedResponse: &fwserver.InvokeActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic( + "warning summary", + "warning detail", + ), + diag.NewErrorDiagnostic( + "error summary", + "error detail", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if testCase.configureProviderReq != nil { + configureProviderResp := &provider.ConfigureResponse{} + testCase.server.ConfigureProvider(context.Background(), testCase.configureProviderReq, configureProviderResp) + } + + response := &fwserver.InvokeActionResponse{} + testCase.server.InvokeAction(context.Background(), testCase.request, response) + + if diff := cmp.Diff(response, testCase.expectedResponse); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/fwserver/server_planaction.go b/internal/fwserver/server_planaction.go index 1a5bc192f..4ed956e20 100644 --- a/internal/fwserver/server_planaction.go +++ b/internal/fwserver/server_planaction.go @@ -6,27 +6,93 @@ package fwserver import ( "context" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-go/tftypes" ) // PlanActionRequest is the framework server request for the PlanAction RPC. type PlanActionRequest struct { - ActionSchema fwschema.Schema - Config *tfsdk.Config + ClientCapabilities action.ModifyPlanClientCapabilities + ActionSchema fwschema.Schema + Action action.Action + Config *tfsdk.Config } // PlanActionResponse is the framework server response for the PlanAction RPC. type PlanActionResponse struct { + Deferred *action.Deferred Diagnostics diag.Diagnostics } // PlanAction implements the framework server PlanAction RPC. func (s *Server) PlanAction(ctx context.Context, req *PlanActionRequest, resp *PlanActionResponse) { - // TODO:Actions: Implementation coming soon... - resp.Diagnostics.AddError( - "PlanAction Not Implemented", - "PlanAction has not yet been implemented in terraform-plugin-framework.", - ) + if req == nil { + return + } + + // TODO:Actions: When linked resources are introduced, pass-through proposed -> planned state similar to + // how normal resource planning works. + + if s.deferred != nil { + logging.FrameworkDebug(ctx, "Provider has deferred response configured, automatically returning deferred response.", + map[string]interface{}{ + logging.KeyDeferredReason: s.deferred.Reason.String(), + }, + ) + + resp.Deferred = &action.Deferred{ + Reason: action.DeferredReason(s.deferred.Reason), + } + return + } + + if actionWithConfigure, ok := req.Action.(action.ActionWithConfigure); ok { + logging.FrameworkTrace(ctx, "Action implements ActionWithConfigure") + + configureReq := action.ConfigureRequest{ + ProviderData: s.ActionConfigureData, + } + configureResp := action.ConfigureResponse{} + + logging.FrameworkTrace(ctx, "Calling provider defined Action Configure") + actionWithConfigure.Configure(ctx, configureReq, &configureResp) + logging.FrameworkTrace(ctx, "Called provider defined Action Configure") + + resp.Diagnostics.Append(configureResp.Diagnostics...) + + if resp.Diagnostics.HasError() { + return + } + } + + if req.Config == nil { + req.Config = &tfsdk.Config{ + Raw: tftypes.NewValue(req.ActionSchema.Type().TerraformType(ctx), nil), + Schema: req.ActionSchema, + } + } + + if actionWithModifyPlan, ok := req.Action.(action.ActionWithModifyPlan); ok { + logging.FrameworkTrace(ctx, "Action implements ActionWithModifyPlan") + + modifyPlanReq := action.ModifyPlanRequest{ + ClientCapabilities: req.ClientCapabilities, + Config: *req.Config, + } + + modifyPlanResp := action.ModifyPlanResponse{ + Diagnostics: resp.Diagnostics, + } + + logging.FrameworkTrace(ctx, "Calling provider defined Action ModifyPlan") + actionWithModifyPlan.ModifyPlan(ctx, modifyPlanReq, &modifyPlanResp) + logging.FrameworkTrace(ctx, "Called provider defined Action ModifyPlan") + + resp.Diagnostics = modifyPlanResp.Diagnostics + resp.Deferred = modifyPlanResp.Deferred + } } diff --git a/internal/fwserver/server_planaction_test.go b/internal/fwserver/server_planaction_test.go index 1ecb5c4f3..d922eb844 100644 --- a/internal/fwserver/server_planaction_test.go +++ b/internal/fwserver/server_planaction_test.go @@ -3,4 +3,260 @@ package fwserver_test -// TODO:Actions: Add unit tests once PlanAction is implemented +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" + "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/provider" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerPlanAction(t *testing.T) { + t.Parallel() + + testType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_required": tftypes.String, + }, + } + + testConfigValue := tftypes.NewValue(testType, map[string]tftypes.Value{ + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }) + + testUnlinkedSchema := schema.UnlinkedSchema{ + Attributes: map[string]schema.Attribute{ + "test_required": schema.StringAttribute{ + Required: true, + }, + }, + } + + testUnlinkedConfig := &tfsdk.Config{ + Raw: testConfigValue, + Schema: testUnlinkedSchema, + } + + testDeferralAllowed := action.ModifyPlanClientCapabilities{ + DeferralAllowed: true, + } + + testCases := map[string]struct { + server *fwserver.Server + request *fwserver.PlanActionRequest + expectedResponse *fwserver.PlanActionResponse + configureProviderReq *provider.ConfigureRequest + }{ + "nil": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + expectedResponse: &fwserver.PlanActionResponse{}, + }, + "unlinked-nil-config-no-modifyplan": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + ActionSchema: testUnlinkedSchema, + Action: &testprovider.Action{}, + }, + expectedResponse: &fwserver.PlanActionResponse{}, + }, + "request-client-capabilities-deferral-allowed": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + ClientCapabilities: testDeferralAllowed, + Config: testUnlinkedConfig, + ActionSchema: testUnlinkedSchema, + Action: &testprovider.ActionWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + if req.ClientCapabilities.DeferralAllowed != true { + resp.Diagnostics.AddError("Unexpected req.ClientCapabilities.DeferralAllowed value", + "expected: true but got: false") + } + + var config struct { + TestRequired types.String `tfsdk:"test_required"` + } + + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + }, + }, + }, + expectedResponse: &fwserver.PlanActionResponse{}, + }, + "request-config": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + Config: testUnlinkedConfig, + ActionSchema: testUnlinkedSchema, + Action: &testprovider.ActionWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + 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()) + } + }, + }, + }, + expectedResponse: &fwserver.PlanActionResponse{}, + }, + "action-configure-data": { + server: &fwserver.Server{ + ActionConfigureData: "test-provider-configure-value", + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + Config: testUnlinkedConfig, + ActionSchema: testUnlinkedSchema, + Action: &testprovider.ActionWithConfigureAndModifyPlan{ + ConfigureMethod: func(ctx context.Context, req action.ConfigureRequest, resp *action.ConfigureResponse) { + providerData, ok := req.ProviderData.(string) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected ConfigureRequest.ProviderData", + fmt.Sprintf("Expected string, got: %T", req.ProviderData), + ) + return + } + + if providerData != "test-provider-configure-value" { + resp.Diagnostics.AddError( + "Unexpected ConfigureRequest.ProviderData", + fmt.Sprintf("Expected test-provider-configure-value, got: %q", providerData), + ) + } + }, + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + // In practice, the Configure method would save the + // provider data to the Action implementation and + // use it here. The fact that Configure is able to + // read the data proves this can work. + }, + }, + }, + expectedResponse: &fwserver.PlanActionResponse{}, + }, + "response-deferral-automatic": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{ + SchemaMethod: func(_ context.Context, _ provider.SchemaRequest, resp *provider.SchemaResponse) {}, + ConfigureMethod: func(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { + resp.Deferred = &provider.Deferred{Reason: provider.DeferredReasonProviderConfigUnknown} + }, + }, + }, + configureProviderReq: &provider.ConfigureRequest{ + ClientCapabilities: provider.ConfigureProviderClientCapabilities{ + DeferralAllowed: true, + }, + }, + request: &fwserver.PlanActionRequest{ + Config: testUnlinkedConfig, + ActionSchema: testUnlinkedSchema, + Action: &testprovider.ActionWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + resp.Diagnostics.AddError("Test assertion failed: ", "ModifyPlan shouldn't be called") + }, + }, + ClientCapabilities: testDeferralAllowed, + }, + expectedResponse: &fwserver.PlanActionResponse{ + Deferred: &action.Deferred{Reason: action.DeferredReasonProviderConfigUnknown}, + }, + }, + "response-deferral-manual": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + Config: testUnlinkedConfig, + ActionSchema: testUnlinkedSchema, + Action: &testprovider.ActionWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + var config struct { + TestRequired types.String `tfsdk:"test_required"` + } + + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + + resp.Deferred = &action.Deferred{Reason: action.DeferredReasonAbsentPrereq} + + if config.TestRequired.ValueString() != "test-config-value" { + resp.Diagnostics.AddError("unexpected req.Config value: %s", config.TestRequired.ValueString()) + } + }, + }, + ClientCapabilities: testDeferralAllowed, + }, + expectedResponse: &fwserver.PlanActionResponse{ + Deferred: &action.Deferred{Reason: action.DeferredReasonAbsentPrereq}, + }, + }, + "response-diagnostics": { + server: &fwserver.Server{ + Provider: &testprovider.Provider{}, + }, + request: &fwserver.PlanActionRequest{ + Config: testUnlinkedConfig, + ActionSchema: testUnlinkedSchema, + Action: &testprovider.ActionWithModifyPlan{ + 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") + }, + }, + }, + expectedResponse: &fwserver.PlanActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic( + "warning summary", + "warning detail", + ), + diag.NewErrorDiagnostic( + "error summary", + "error detail", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if testCase.configureProviderReq != nil { + configureProviderResp := &provider.ConfigureResponse{} + testCase.server.ConfigureProvider(context.Background(), testCase.configureProviderReq, configureProviderResp) + } + + response := &fwserver.PlanActionResponse{} + testCase.server.PlanAction(context.Background(), testCase.request, response) + + if diff := cmp.Diff(response, testCase.expectedResponse); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/proto5server/server_getmetadata_test.go b/internal/proto5server/server_getmetadata_test.go index 6f28652f4..29fe8e50e 100644 --- a/internal/proto5server/server_getmetadata_test.go +++ b/internal/proto5server/server_getmetadata_test.go @@ -349,6 +349,10 @@ func TestServerGetMetadata(t *testing.T) { } // Prevent false positives with random map access in testing + sort.Slice(got.Actions, func(i int, j int) bool { + return got.Actions[i].TypeName < got.Actions[j].TypeName + }) + sort.Slice(got.DataSources, func(i int, j int) bool { return got.DataSources[i].TypeName < got.DataSources[j].TypeName }) diff --git a/internal/proto5server/server_invokeaction.go b/internal/proto5server/server_invokeaction.go index e7e2d6fd6..216a067b3 100644 --- a/internal/proto5server/server_invokeaction.go +++ b/internal/proto5server/server_invokeaction.go @@ -60,19 +60,34 @@ func (s *Server) InvokeAction(ctx context.Context, proto5Req *tfprotov5.InvokeAc return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) } - s.FrameworkServer.InvokeAction(ctx, fwReq, fwResp) - - // TODO:Actions: This is a stub implementation, so we aren't currently exposing any streaming mechanism to the developer. - // That will eventually need to change to send progress events back to Terraform. - // - // This logic will likely need to be moved over to the "toproto" package as well. protoStream := &tfprotov5.InvokeActionServerStream{ Events: func(push func(tfprotov5.InvokeActionEvent) bool) { - push(tfprotov5.InvokeActionEvent{ - Type: tfprotov5.CompletedInvokeActionEventType{ - Diagnostics: toproto5.Diagnostics(ctx, fwResp.Diagnostics), - }, - }) + // Create a channel for framework to receive progress events + progressChan := make(chan fwserver.InvokeProgressEvent) + fwResp.ProgressEvents = progressChan + + // Create a channel to be triggered when the invoke action method has finished + completedChan := make(chan any) + go func() { + s.FrameworkServer.InvokeAction(ctx, fwReq, fwResp) + close(completedChan) + }() + + for { + select { + // Actions can only push one completed event and it's automatically handled by the framework + // by closing the completed channel above. + case <-completedChan: + push(toproto5.CompletedInvokeActionEventType(ctx, fwResp)) + return + + // Actions can push multiple progress events + case progressEvent := <-fwResp.ProgressEvents: + if !push(toproto5.ProgressInvokeActionEventType(ctx, progressEvent)) { + return + } + } + } }, } diff --git a/internal/proto5server/server_invokeaction_test.go b/internal/proto5server/server_invokeaction_test.go index 4cc50c03a..6d08c770d 100644 --- a/internal/proto5server/server_invokeaction_test.go +++ b/internal/proto5server/server_invokeaction_test.go @@ -1,6 +1,317 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 -package proto5server_test +package proto5server -// TODO:Actions: Add unit tests once InvokeAction is implemented +import ( + "context" + "fmt" + "slices" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerInvokeAction(t *testing.T) { + t.Parallel() + + testType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_required": tftypes.String, + }, + } + + testConfigDynamicValue := 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{ + 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 = schema.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{}, + }, + }, + }, + "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: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{}, + }, + }, + }, + "response-progress-events": { + 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) { + resp.SendProgress(action.InvokeProgressEvent{Message: "progress event 1"}) + resp.SendProgress(action.InvokeProgressEvent{Message: "progress event 2"}) + resp.SendProgress(action.InvokeProgressEvent{Message: "progress event 3"}) + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: "progress event 1", + }, + }, + { + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: "progress event 2", + }, + }, + { + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: "progress event 3", + }, + }, + { + Type: tfprotov5.CompletedInvokeActionEventType{}, + }, + }, + }, + "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.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) { + resp.Diagnostics.AddWarning("warning summary", "warning detail") + resp.Diagnostics.AddError("error summary", "error detail") + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + }, + }, + }, + }, + "response-diagnostics-with-progress-events": { + 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) { + for i := 0; i < 5; i++ { + resp.SendProgress(action.InvokeProgressEvent{Message: fmt.Sprintf("progress event %d", i+1)}) + } + + resp.Diagnostics.AddWarning("warning summary", "warning detail") + resp.Diagnostics.AddError("error summary", "error detail") + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.InvokeActionRequest{ + Config: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: "progress event 1", + }, + }, + { + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: "progress event 2", + }, + }, + { + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: "progress event 3", + }, + }, + { + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: "progress event 4", + }, + }, + { + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: "progress event 5", + }, + }, + { + Type: tfprotov5.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.server.InvokeAction(context.Background(), testCase.request) + + if diff := cmp.Diff(testCase.expectedError, err); diff != "" { + t.Errorf("unexpected error difference: %s", diff) + } + + if diff := cmp.Diff(testCase.expectedEvents, slices.Collect(got.Events)); diff != "" { + t.Errorf("unexpected response difference: %s", diff) + } + }) + } +} diff --git a/internal/proto5server/server_planaction_test.go b/internal/proto5server/server_planaction_test.go index ccf86051e..d3017e4a9 100644 --- a/internal/proto5server/server_planaction_test.go +++ b/internal/proto5server/server_planaction_test.go @@ -1,6 +1,179 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 -package proto5server_test +package proto5server -// TODO:Actions: Add unit tests once PlanAction is implemented +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerPlanAction(t *testing.T) { + t.Parallel() + + testType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_required": tftypes.String, + }, + } + + testConfigDynamicValue := 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{ + Required: true, + }, + }, + } + + testCases := map[string]struct { + server *Server + request *tfprotov5.PlanActionRequest + expectedError error + expectedResponse *tfprotov5.PlanActionResponse + }{ + "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 = schema.UnlinkedSchema{} + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov5.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov5.PlanActionResponse{}, + }, + "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.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) { + 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.PlanActionRequest{ + Config: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov5.PlanActionResponse{}, + }, + "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: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov5.PlanActionResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.server.PlanAction(context.Background(), testCase.request) + + if diff := cmp.Diff(testCase.expectedError, err); diff != "" { + t.Errorf("unexpected error difference: %s", diff) + } + + if diff := cmp.Diff(testCase.expectedResponse, got); diff != "" { + t.Errorf("unexpected response difference: %s", diff) + } + }) + } +} diff --git a/internal/proto6server/server_getmetadata_test.go b/internal/proto6server/server_getmetadata_test.go index 090091f27..2209429da 100644 --- a/internal/proto6server/server_getmetadata_test.go +++ b/internal/proto6server/server_getmetadata_test.go @@ -349,6 +349,10 @@ func TestServerGetMetadata(t *testing.T) { } // Prevent false positives with random map access in testing + sort.Slice(got.Actions, func(i int, j int) bool { + return got.Actions[i].TypeName < got.Actions[j].TypeName + }) + sort.Slice(got.DataSources, func(i int, j int) bool { return got.DataSources[i].TypeName < got.DataSources[j].TypeName }) diff --git a/internal/proto6server/server_invokeaction.go b/internal/proto6server/server_invokeaction.go index 3a6d78cee..3803dcdea 100644 --- a/internal/proto6server/server_invokeaction.go +++ b/internal/proto6server/server_invokeaction.go @@ -60,19 +60,34 @@ func (s *Server) InvokeAction(ctx context.Context, proto6Req *tfprotov6.InvokeAc return invokeActionErrorDiagnostics(ctx, fwResp.Diagnostics) } - s.FrameworkServer.InvokeAction(ctx, fwReq, fwResp) - - // TODO:Actions: This is a stub implementation, so we aren't currently exposing any streaming mechanism to the developer. - // That will eventually need to change to send progress events back to Terraform. - // - // This logic will likely need to be moved over to the "toproto" package as well. protoStream := &tfprotov6.InvokeActionServerStream{ Events: func(push func(tfprotov6.InvokeActionEvent) bool) { - push(tfprotov6.InvokeActionEvent{ - Type: tfprotov6.CompletedInvokeActionEventType{ - Diagnostics: toproto6.Diagnostics(ctx, fwResp.Diagnostics), - }, - }) + // Create a channel for framework to receive progress events + progressChan := make(chan fwserver.InvokeProgressEvent) + fwResp.ProgressEvents = progressChan + + // Create a channel to be triggered when the invoke action method has finished + completedChan := make(chan any) + go func() { + s.FrameworkServer.InvokeAction(ctx, fwReq, fwResp) + close(completedChan) + }() + + for { + select { + // Actions can only push one completed event and it's automatically handled by the framework + // by closing the completed channel above. + case <-completedChan: + push(toproto6.CompletedInvokeActionEventType(ctx, fwResp)) + return + + // Actions can push multiple progress events + case progressEvent := <-fwResp.ProgressEvents: + if !push(toproto6.ProgressInvokeActionEventType(ctx, progressEvent)) { + return + } + } + } }, } diff --git a/internal/proto6server/server_invokeaction_test.go b/internal/proto6server/server_invokeaction_test.go index 7bc3d2a68..0c36bf980 100644 --- a/internal/proto6server/server_invokeaction_test.go +++ b/internal/proto6server/server_invokeaction_test.go @@ -1,6 +1,317 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 -package proto6server_test +package proto6server -// TODO:Actions: Add unit tests once InvokeAction is implemented +import ( + "context" + "fmt" + "slices" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerInvokeAction(t *testing.T) { + t.Parallel() + + testType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_required": tftypes.String, + }, + } + + testConfigDynamicValue := 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{ + 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 = schema.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{}, + }, + }, + }, + "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: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{}, + }, + }, + }, + "response-progress-events": { + 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) { + resp.SendProgress(action.InvokeProgressEvent{Message: "progress event 1"}) + resp.SendProgress(action.InvokeProgressEvent{Message: "progress event 2"}) + resp.SendProgress(action.InvokeProgressEvent{Message: "progress event 3"}) + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: "progress event 1", + }, + }, + { + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: "progress event 2", + }, + }, + { + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: "progress event 3", + }, + }, + { + Type: tfprotov6.CompletedInvokeActionEventType{}, + }, + }, + }, + "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.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) { + resp.Diagnostics.AddWarning("warning summary", "warning detail") + resp.Diagnostics.AddError("error summary", "error detail") + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + }, + }, + }, + }, + "response-diagnostics-with-progress-events": { + 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) { + for i := 0; i < 5; i++ { + resp.SendProgress(action.InvokeProgressEvent{Message: fmt.Sprintf("progress event %d", i+1)}) + } + + resp.Diagnostics.AddWarning("warning summary", "warning detail") + resp.Diagnostics.AddError("error summary", "error detail") + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.InvokeActionRequest{ + Config: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedEvents: []tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: "progress event 1", + }, + }, + { + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: "progress event 2", + }, + }, + { + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: "progress event 3", + }, + }, + { + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: "progress event 4", + }, + }, + { + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: "progress event 5", + }, + }, + { + Type: tfprotov6.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.server.InvokeAction(context.Background(), testCase.request) + + if diff := cmp.Diff(testCase.expectedError, err); diff != "" { + t.Errorf("unexpected error difference: %s", diff) + } + + if diff := cmp.Diff(testCase.expectedEvents, slices.Collect(got.Events)); diff != "" { + t.Errorf("unexpected response difference: %s", diff) + } + }) + } +} diff --git a/internal/proto6server/server_planaction_test.go b/internal/proto6server/server_planaction_test.go index 0ff1b5e7c..8d4093e2f 100644 --- a/internal/proto6server/server_planaction_test.go +++ b/internal/proto6server/server_planaction_test.go @@ -1,6 +1,179 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 -package proto6server_test +package proto6server -// TODO:Actions: Add unit tests once PlanAction is implemented +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestServerPlanAction(t *testing.T) { + t.Parallel() + + testType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_required": tftypes.String, + }, + } + + testConfigDynamicValue := 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{ + Required: true, + }, + }, + } + + testCases := map[string]struct { + server *Server + request *tfprotov6.PlanActionRequest + expectedError error + expectedResponse *tfprotov6.PlanActionResponse + }{ + "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 = schema.UnlinkedSchema{} + }, + MetadataMethod: func(_ context.Context, _ action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = "test_action" + }, + } + }, + } + }, + }, + }, + }, + request: &tfprotov6.PlanActionRequest{ + Config: testEmptyDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov6.PlanActionResponse{}, + }, + "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.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) { + 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.PlanActionRequest{ + Config: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov6.PlanActionResponse{}, + }, + "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: testConfigDynamicValue, + ActionType: "test_action", + }, + expectedResponse: &tfprotov6.PlanActionResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "warning summary", + Detail: "warning detail", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "error summary", + Detail: "error detail", + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.server.PlanAction(context.Background(), testCase.request) + + if diff := cmp.Diff(testCase.expectedError, err); diff != "" { + t.Errorf("unexpected error difference: %s", diff) + } + + if diff := cmp.Diff(testCase.expectedResponse, got); diff != "" { + t.Errorf("unexpected response difference: %s", diff) + } + }) + } +} diff --git a/internal/testing/testprovider/action.go b/internal/testing/testprovider/action.go index 610ee8326..19f0260bd 100644 --- a/internal/testing/testprovider/action.go +++ b/internal/testing/testprovider/action.go @@ -16,6 +16,7 @@ type Action struct { // Action interface methods MetadataMethod func(context.Context, action.MetadataRequest, *action.MetadataResponse) SchemaMethod func(context.Context, action.SchemaRequest, *action.SchemaResponse) + InvokeMethod func(context.Context, action.InvokeRequest, *action.InvokeResponse) } // Metadata satisfies the action.Action interface. @@ -35,3 +36,12 @@ func (d *Action) Schema(ctx context.Context, req action.SchemaRequest, resp *act d.SchemaMethod(ctx, req, resp) } + +// Invoke satisfies the action.Action interface. +func (d *Action) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + if d.InvokeMethod == nil { + return + } + + d.InvokeMethod(ctx, req, resp) +} diff --git a/internal/testing/testprovider/actionwithconfigure.go b/internal/testing/testprovider/actionwithconfigure.go new file mode 100644 index 000000000..415301ed1 --- /dev/null +++ b/internal/testing/testprovider/actionwithconfigure.go @@ -0,0 +1,30 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/action" +) + +var _ action.Action = &ActionWithConfigure{} +var _ action.ActionWithConfigure = &ActionWithConfigure{} + +// Declarative action.ActionWithConfigure for unit testing. +type ActionWithConfigure struct { + *Action + + // ActionWithConfigure interface methods + ConfigureMethod func(context.Context, action.ConfigureRequest, *action.ConfigureResponse) +} + +// Configure satisfies the action.ActionWithConfigure interface. +func (r *ActionWithConfigure) Configure(ctx context.Context, req action.ConfigureRequest, resp *action.ConfigureResponse) { + if r.ConfigureMethod == nil { + return + } + + r.ConfigureMethod(ctx, req, resp) +} diff --git a/internal/testing/testprovider/actionwithconfigureandmodifyplan.go b/internal/testing/testprovider/actionwithconfigureandmodifyplan.go new file mode 100644 index 000000000..f4ed4963d --- /dev/null +++ b/internal/testing/testprovider/actionwithconfigureandmodifyplan.go @@ -0,0 +1,43 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/action" +) + +var _ action.Action = &ActionWithConfigureAndModifyPlan{} +var _ action.ActionWithConfigure = &ActionWithConfigureAndModifyPlan{} +var _ action.ActionWithModifyPlan = &ActionWithConfigureAndModifyPlan{} + +// Declarative action.ActionWithConfigureAndModifyPlan for unit testing. +type ActionWithConfigureAndModifyPlan struct { + *Action + + // ActionWithConfigureAndModifyPlan interface methods + ConfigureMethod func(context.Context, action.ConfigureRequest, *action.ConfigureResponse) + + // ActionWithModifyPlan interface methods + ModifyPlanMethod func(context.Context, action.ModifyPlanRequest, *action.ModifyPlanResponse) +} + +// Configure satisfies the action.ActionWithConfigureAndModifyPlan interface. +func (r *ActionWithConfigureAndModifyPlan) Configure(ctx context.Context, req action.ConfigureRequest, resp *action.ConfigureResponse) { + if r.ConfigureMethod == nil { + return + } + + r.ConfigureMethod(ctx, req, resp) +} + +// ModifyPlan satisfies the action.ActionWithModifyPlan interface. +func (r *ActionWithConfigureAndModifyPlan) ModifyPlan(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + if r.ModifyPlanMethod == nil { + return + } + + r.ModifyPlanMethod(ctx, req, resp) +} diff --git a/internal/testing/testprovider/actionwithmodifyplan.go b/internal/testing/testprovider/actionwithmodifyplan.go new file mode 100644 index 000000000..5f8cd3863 --- /dev/null +++ b/internal/testing/testprovider/actionwithmodifyplan.go @@ -0,0 +1,30 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/action" +) + +var _ action.Action = &ActionWithModifyPlan{} +var _ action.ActionWithModifyPlan = &ActionWithModifyPlan{} + +// Declarative action.ActionWithModifyPlan for unit testing. +type ActionWithModifyPlan struct { + *Action + + // ActionWithModifyPlan interface methods + ModifyPlanMethod func(context.Context, action.ModifyPlanRequest, *action.ModifyPlanResponse) +} + +// ModifyPlan satisfies the action.ActionWithModifyPlan interface. +func (p *ActionWithModifyPlan) ModifyPlan(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + if p.ModifyPlanMethod == nil { + return + } + + p.ModifyPlanMethod(ctx, req, resp) +} diff --git a/internal/toproto5/deferred.go b/internal/toproto5/deferred.go index 42049a4da..c5f35d0c7 100644 --- a/internal/toproto5/deferred.go +++ b/internal/toproto5/deferred.go @@ -6,6 +6,7 @@ package toproto5 import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -37,3 +38,12 @@ func EphemeralResourceDeferred(fw *ephemeral.Deferred) *tfprotov5.Deferred { Reason: tfprotov5.DeferredReason(fw.Reason), } } + +func ActionDeferred(fw *action.Deferred) *tfprotov5.Deferred { + if fw == nil { + return nil + } + return &tfprotov5.Deferred{ + Reason: tfprotov5.DeferredReason(fw.Reason), + } +} diff --git a/internal/toproto5/invoke_action_event.go b/internal/toproto5/invoke_action_event.go new file mode 100644 index 000000000..d52e8a6eb --- /dev/null +++ b/internal/toproto5/invoke_action_event.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto5 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +func ProgressInvokeActionEventType(ctx context.Context, event fwserver.InvokeProgressEvent) tfprotov5.InvokeActionEvent { + return tfprotov5.InvokeActionEvent{ + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: event.Message, + }, + } +} + +func CompletedInvokeActionEventType(ctx context.Context, event *fwserver.InvokeActionResponse) tfprotov5.InvokeActionEvent { + return tfprotov5.InvokeActionEvent{ + Type: tfprotov5.CompletedInvokeActionEventType{ + // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented + Diagnostics: Diagnostics(ctx, event.Diagnostics), + }, + } +} diff --git a/internal/toproto5/invoke_action_event_test.go b/internal/toproto5/invoke_action_event_test.go new file mode 100644 index 000000000..7921e9b35 --- /dev/null +++ b/internal/toproto5/invoke_action_event_test.go @@ -0,0 +1,93 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto5_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" +) + +func TestProgressInvokeActionEventType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + fw fwserver.InvokeProgressEvent + expected tfprotov5.InvokeActionEvent + }{ + "message": { + fw: fwserver.InvokeProgressEvent{ + Message: "hello world", + }, + expected: tfprotov5.InvokeActionEvent{ + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: "hello world", + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto5.ProgressInvokeActionEventType(context.Background(), testCase.fw) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestCompletedInvokeActionEventType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + fw *fwserver.InvokeActionResponse + expected tfprotov5.InvokeActionEvent + }{ + "diagnostics": { + fw: &fwserver.InvokeActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + }, + expected: tfprotov5.InvokeActionEvent{ + Type: tfprotov5.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "test warning summary", + Detail: "test warning details", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "test error summary", + Detail: "test error details", + }, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto5.CompletedInvokeActionEventType(context.Background(), testCase.fw) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/toproto5/planaction.go b/internal/toproto5/planaction.go index b55fd4557..06f12faaf 100644 --- a/internal/toproto5/planaction.go +++ b/internal/toproto5/planaction.go @@ -19,9 +19,10 @@ func PlanActionResponse(ctx context.Context, fw *fwserver.PlanActionResponse) *t proto5 := &tfprotov5.PlanActionResponse{ Diagnostics: Diagnostics(ctx, fw.Diagnostics), + Deferred: ActionDeferred(fw.Deferred), } - // TODO:Actions: Here we need to set deferred and linked resource data + // TODO:Actions: Here we need to set linked resource data return proto5 } diff --git a/internal/toproto5/planaction_test.go b/internal/toproto5/planaction_test.go index 41e665462..44aca43ae 100644 --- a/internal/toproto5/planaction_test.go +++ b/internal/toproto5/planaction_test.go @@ -3,4 +3,83 @@ package toproto5_test -// TODO:Actions: Add unit tests once this mapping logic is complete +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + + "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" +) + +func TestPlanActionResponse(t *testing.T) { + t.Parallel() + + testDeferral := &action.Deferred{ + Reason: action.DeferredReasonAbsentPrereq, + } + + testProto5Deferred := &tfprotov5.Deferred{ + Reason: tfprotov5.DeferredReasonAbsentPrereq, + } + + testCases := map[string]struct { + input *fwserver.PlanActionResponse + expected *tfprotov5.PlanActionResponse + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &fwserver.PlanActionResponse{}, + expected: &tfprotov5.PlanActionResponse{}, + }, + "diagnostics": { + input: &fwserver.PlanActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + }, + expected: &tfprotov5.PlanActionResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "test warning summary", + Detail: "test warning details", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "test error summary", + Detail: "test error details", + }, + }, + }, + }, + "deferral": { + input: &fwserver.PlanActionResponse{ + Deferred: testDeferral, + }, + expected: &tfprotov5.PlanActionResponse{ + Deferred: testProto5Deferred, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto5.PlanActionResponse(context.Background(), testCase.input) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/toproto6/deferred.go b/internal/toproto6/deferred.go index fab64fe05..362c843bb 100644 --- a/internal/toproto6/deferred.go +++ b/internal/toproto6/deferred.go @@ -6,6 +6,7 @@ package toproto6 import ( "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -37,3 +38,12 @@ func EphemeralResourceDeferred(fw *ephemeral.Deferred) *tfprotov6.Deferred { Reason: tfprotov6.DeferredReason(fw.Reason), } } + +func ActionDeferred(fw *action.Deferred) *tfprotov6.Deferred { + if fw == nil { + return nil + } + return &tfprotov6.Deferred{ + Reason: tfprotov6.DeferredReason(fw.Reason), + } +} diff --git a/internal/toproto6/invoke_action_event.go b/internal/toproto6/invoke_action_event.go new file mode 100644 index 000000000..c4410ae4e --- /dev/null +++ b/internal/toproto6/invoke_action_event.go @@ -0,0 +1,28 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto6 + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +func ProgressInvokeActionEventType(ctx context.Context, event fwserver.InvokeProgressEvent) tfprotov6.InvokeActionEvent { + return tfprotov6.InvokeActionEvent{ + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: event.Message, + }, + } +} + +func CompletedInvokeActionEventType(ctx context.Context, event *fwserver.InvokeActionResponse) tfprotov6.InvokeActionEvent { + return tfprotov6.InvokeActionEvent{ + Type: tfprotov6.CompletedInvokeActionEventType{ + // TODO:Actions: Add linked resources once lifecycle/linked actions are implemented + Diagnostics: Diagnostics(ctx, event.Diagnostics), + }, + } +} diff --git a/internal/toproto6/invoke_action_event_test.go b/internal/toproto6/invoke_action_event_test.go new file mode 100644 index 000000000..6e4ad5893 --- /dev/null +++ b/internal/toproto6/invoke_action_event_test.go @@ -0,0 +1,93 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package toproto6_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" + "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" +) + +func TestProgressInvokeActionEventType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + fw fwserver.InvokeProgressEvent + expected tfprotov6.InvokeActionEvent + }{ + "message": { + fw: fwserver.InvokeProgressEvent{ + Message: "hello world", + }, + expected: tfprotov6.InvokeActionEvent{ + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: "hello world", + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto6.ProgressInvokeActionEventType(context.Background(), testCase.fw) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestCompletedInvokeActionEventType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + fw *fwserver.InvokeActionResponse + expected tfprotov6.InvokeActionEvent + }{ + "diagnostics": { + fw: &fwserver.InvokeActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + }, + expected: tfprotov6.InvokeActionEvent{ + Type: tfprotov6.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "test warning summary", + Detail: "test warning details", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "test error summary", + Detail: "test error details", + }, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto6.CompletedInvokeActionEventType(context.Background(), testCase.fw) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/toproto6/planaction.go b/internal/toproto6/planaction.go index 9d4f86ac8..6411005f4 100644 --- a/internal/toproto6/planaction.go +++ b/internal/toproto6/planaction.go @@ -19,9 +19,10 @@ func PlanActionResponse(ctx context.Context, fw *fwserver.PlanActionResponse) *t proto6 := &tfprotov6.PlanActionResponse{ Diagnostics: Diagnostics(ctx, fw.Diagnostics), + Deferred: ActionDeferred(fw.Deferred), } - // TODO:Actions: Here we need to set deferred and linked resource data + // TODO:Actions: Here we need to set linked resource data return proto6 } diff --git a/internal/toproto6/planaction_test.go b/internal/toproto6/planaction_test.go index 29cade55e..7818597fb 100644 --- a/internal/toproto6/planaction_test.go +++ b/internal/toproto6/planaction_test.go @@ -3,4 +3,83 @@ package toproto6_test -// TODO:Actions: Add unit tests once this mapping logic is complete +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "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" +) + +func TestPlanActionResponse(t *testing.T) { + t.Parallel() + + testDeferral := &action.Deferred{ + Reason: action.DeferredReasonAbsentPrereq, + } + + testProto6Deferred := &tfprotov6.Deferred{ + Reason: tfprotov6.DeferredReasonAbsentPrereq, + } + + testCases := map[string]struct { + input *fwserver.PlanActionResponse + expected *tfprotov6.PlanActionResponse + }{ + "nil": { + input: nil, + expected: nil, + }, + "empty": { + input: &fwserver.PlanActionResponse{}, + expected: &tfprotov6.PlanActionResponse{}, + }, + "diagnostics": { + input: &fwserver.PlanActionResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewWarningDiagnostic("test warning summary", "test warning details"), + diag.NewErrorDiagnostic("test error summary", "test error details"), + }, + }, + expected: &tfprotov6.PlanActionResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityWarning, + Summary: "test warning summary", + Detail: "test warning details", + }, + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "test error summary", + Detail: "test error details", + }, + }, + }, + }, + "deferral": { + input: &fwserver.PlanActionResponse{ + Deferred: testDeferral, + }, + expected: &tfprotov6.PlanActionResponse{ + Deferred: testProto6Deferred, + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := toproto6.PlanActionResponse(context.Background(), testCase.input) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/provider/configure.go b/provider/configure.go index 59e9ead44..49e2f5203 100644 --- a/provider/configure.go +++ b/provider/configure.go @@ -67,6 +67,11 @@ type ConfigureResponse struct { // EphemeralResource type that implements the Configure method. EphemeralResourceData any + // ActionData is provider-defined data, clients, etc. that is + // passed to [action.ConfigureRequest.ProviderData] for each + // Action type that implements the Configure method. + ActionData any + // Deferred indicates that Terraform should automatically defer // all resources and data sources for this provider. //