diff --git a/.changes/unreleased/FEATURES-20240520-180458.yaml b/.changes/unreleased/FEATURES-20240520-180458.yaml new file mode 100644 index 000000000..2127002c5 --- /dev/null +++ b/.changes/unreleased/FEATURES-20240520-180458.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'provider: Add `Deferred` field to `ConfigureResponse` + which indicates a provider deferred action to the Terraform client' +time: 2024-05-20T18:04:58.852448-04:00 +custom: + Issue: "1002" diff --git a/.changes/unreleased/FEATURES-20240520-180735.yaml b/.changes/unreleased/FEATURES-20240520-180735.yaml new file mode 100644 index 000000000..5d150c86d --- /dev/null +++ b/.changes/unreleased/FEATURES-20240520-180735.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'provider: Add `ClientCapabilities` field to `ConfigureRequest` which specifies + optionally supported protocol features for the Terraform client' +time: 2024-05-20T18:07:35.862641-04:00 +custom: + Issue: "1002" diff --git a/internal/fromproto5/client_capabilities.go b/internal/fromproto5/client_capabilities.go index 8f1075737..3a6347dc4 100644 --- a/internal/fromproto5/client_capabilities.go +++ b/internal/fromproto5/client_capabilities.go @@ -7,9 +7,23 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" ) +func ConfigureProviderClientCapabilities(in *tfprotov5.ConfigureProviderClientCapabilities) provider.ConfigureProviderClientCapabilities { + if in == nil { + // Client did not indicate any supported capabilities + return provider.ConfigureProviderClientCapabilities{ + DeferralAllowed: false, + } + } + + return provider.ConfigureProviderClientCapabilities{ + DeferralAllowed: in.DeferralAllowed, + } +} + func ReadDataSourceClientCapabilities(in *tfprotov5.ReadDataSourceClientCapabilities) datasource.ReadClientCapabilities { if in == nil { // Client did not indicate any supported capabilities diff --git a/internal/fromproto5/configureprovider.go b/internal/fromproto5/configureprovider.go index 9dc0f1115..bb6b8835e 100644 --- a/internal/fromproto5/configureprovider.go +++ b/internal/fromproto5/configureprovider.go @@ -6,10 +6,11 @@ package fromproto5 import ( "context" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/provider" - "github.com/hashicorp/terraform-plugin-go/tfprotov5" ) // ConfigureProviderRequest returns the *fwserver.ConfigureProviderRequest @@ -20,7 +21,8 @@ func ConfigureProviderRequest(ctx context.Context, proto5 *tfprotov5.ConfigurePr } fw := &provider.ConfigureRequest{ - TerraformVersion: proto5.TerraformVersion, + TerraformVersion: proto5.TerraformVersion, + ClientCapabilities: ConfigureProviderClientCapabilities(proto5.ClientCapabilities), } config, diags := Config(ctx, proto5.Config, providerSchema) diff --git a/internal/fromproto5/configureprovider_test.go b/internal/fromproto5/configureprovider_test.go index b1c6ce6a4..f61f925a3 100644 --- a/internal/fromproto5/configureprovider_test.go +++ b/internal/fromproto5/configureprovider_test.go @@ -8,14 +8,15 @@ import ( "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/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-go/tfprotov5" - "github.com/hashicorp/terraform-plugin-go/tftypes" ) func TestConfigureProviderRequest(t *testing.T) { @@ -94,6 +95,26 @@ func TestConfigureProviderRequest(t *testing.T) { TerraformVersion: "99.99.99", }, }, + "client-capabilities": { + input: &tfprotov5.ConfigureProviderRequest{ + ClientCapabilities: &tfprotov5.ConfigureProviderClientCapabilities{ + DeferralAllowed: true, + }, + }, + expected: &provider.ConfigureRequest{ + ClientCapabilities: provider.ConfigureProviderClientCapabilities{ + DeferralAllowed: true, + }, + }, + }, + "client-capabilities-unset": { + input: &tfprotov5.ConfigureProviderRequest{}, + expected: &provider.ConfigureRequest{ + ClientCapabilities: provider.ConfigureProviderClientCapabilities{ + DeferralAllowed: false, + }, + }, + }, } for name, testCase := range testCases { diff --git a/internal/fromproto5/planresourcechange.go b/internal/fromproto5/planresourcechange.go index 3aaed62b8..5bd24c1dd 100644 --- a/internal/fromproto5/planresourcechange.go +++ b/internal/fromproto5/planresourcechange.go @@ -17,7 +17,7 @@ import ( // PlanResourceChangeRequest returns the *fwserver.PlanResourceChangeRequest // equivalent of a *tfprotov5.PlanResourceChangeRequest. -func PlanResourceChangeRequest(ctx context.Context, proto5 *tfprotov5.PlanResourceChangeRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema) (*fwserver.PlanResourceChangeRequest, diag.Diagnostics) { +func PlanResourceChangeRequest(ctx context.Context, proto5 *tfprotov5.PlanResourceChangeRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema, resourceBehavior resource.ResourceBehavior) (*fwserver.PlanResourceChangeRequest, diag.Diagnostics) { if proto5 == nil { return nil, nil } @@ -39,6 +39,7 @@ func PlanResourceChangeRequest(ctx context.Context, proto5 *tfprotov5.PlanResour } fw := &fwserver.PlanResourceChangeRequest{ + ResourceBehavior: resourceBehavior, ResourceSchema: resourceSchema, Resource: reqResource, ClientCapabilities: ModifyPlanClientCapabilities(proto5.ClientCapabilities), diff --git a/internal/fromproto5/planresourcechange_test.go b/internal/fromproto5/planresourcechange_test.go index f20a94c7e..64dcad0f8 100644 --- a/internal/fromproto5/planresourcechange_test.go +++ b/internal/fromproto5/planresourcechange_test.go @@ -56,6 +56,7 @@ func TestPlanResourceChangeRequest(t *testing.T) { testCases := map[string]struct { input *tfprotov5.PlanResourceChangeRequest + resourceBehavior resource.ResourceBehavior resourceSchema fwschema.Schema resource resource.Resource providerMetaSchema fwschema.Schema @@ -241,6 +242,23 @@ func TestPlanResourceChangeRequest(t *testing.T) { ResourceSchema: testFwSchema, }, }, + "resource-behavior": { + input: &tfprotov5.PlanResourceChangeRequest{}, + resourceSchema: testFwSchema, + resourceBehavior: resource.ResourceBehavior{ + ProviderDeferred: resource.ProviderDeferredBehavior{ + EnablePlanModification: true, + }, + }, + expected: &fwserver.PlanResourceChangeRequest{ + ResourceBehavior: resource.ResourceBehavior{ + ProviderDeferred: resource.ProviderDeferredBehavior{ + EnablePlanModification: true, + }, + }, + ResourceSchema: testFwSchema, + }, + }, } for name, testCase := range testCases { @@ -249,7 +267,7 @@ func TestPlanResourceChangeRequest(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto5.PlanResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema) + got, diags := fromproto5.PlanResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema, testCase.resourceBehavior) if diff := cmp.Diff(got, testCase.expected, cmp.AllowUnexported(privatestate.ProviderData{})); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fromproto6/client_capabilities.go b/internal/fromproto6/client_capabilities.go index f459291ab..6742a0303 100644 --- a/internal/fromproto6/client_capabilities.go +++ b/internal/fromproto6/client_capabilities.go @@ -7,9 +7,23 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" ) +func ConfigureProviderClientCapabilities(in *tfprotov6.ConfigureProviderClientCapabilities) provider.ConfigureProviderClientCapabilities { + if in == nil { + // Client did not indicate any supported capabilities + return provider.ConfigureProviderClientCapabilities{ + DeferralAllowed: false, + } + } + + return provider.ConfigureProviderClientCapabilities{ + DeferralAllowed: in.DeferralAllowed, + } +} + func ReadDataSourceClientCapabilities(in *tfprotov6.ReadDataSourceClientCapabilities) datasource.ReadClientCapabilities { if in == nil { // Client did not indicate any supported capabilities diff --git a/internal/fromproto6/configureprovider.go b/internal/fromproto6/configureprovider.go index 733b76ca2..19e470a21 100644 --- a/internal/fromproto6/configureprovider.go +++ b/internal/fromproto6/configureprovider.go @@ -6,10 +6,11 @@ package fromproto6 import ( "context" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/provider" - "github.com/hashicorp/terraform-plugin-go/tfprotov6" ) // ConfigureProviderRequest returns the *fwserver.ConfigureProviderRequest @@ -20,7 +21,8 @@ func ConfigureProviderRequest(ctx context.Context, proto6 *tfprotov6.ConfigurePr } fw := &provider.ConfigureRequest{ - TerraformVersion: proto6.TerraformVersion, + TerraformVersion: proto6.TerraformVersion, + ClientCapabilities: ConfigureProviderClientCapabilities(proto6.ClientCapabilities), } config, diags := Config(ctx, proto6.Config, providerSchema) diff --git a/internal/fromproto6/configureprovider_test.go b/internal/fromproto6/configureprovider_test.go index afceedbcd..47a3d778a 100644 --- a/internal/fromproto6/configureprovider_test.go +++ b/internal/fromproto6/configureprovider_test.go @@ -8,14 +8,15 @@ import ( "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/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fromproto6" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-go/tfprotov6" - "github.com/hashicorp/terraform-plugin-go/tftypes" ) func TestConfigureProviderRequest(t *testing.T) { @@ -94,6 +95,26 @@ func TestConfigureProviderRequest(t *testing.T) { TerraformVersion: "99.99.99", }, }, + "client-capabilities": { + input: &tfprotov6.ConfigureProviderRequest{ + ClientCapabilities: &tfprotov6.ConfigureProviderClientCapabilities{ + DeferralAllowed: true, + }, + }, + expected: &provider.ConfigureRequest{ + ClientCapabilities: provider.ConfigureProviderClientCapabilities{ + DeferralAllowed: true, + }, + }, + }, + "client-capabilities-unset": { + input: &tfprotov6.ConfigureProviderRequest{}, + expected: &provider.ConfigureRequest{ + ClientCapabilities: provider.ConfigureProviderClientCapabilities{ + DeferralAllowed: false, + }, + }, + }, } for name, testCase := range testCases { diff --git a/internal/fromproto6/planresourcechange.go b/internal/fromproto6/planresourcechange.go index 6a10ee180..6b95f0789 100644 --- a/internal/fromproto6/planresourcechange.go +++ b/internal/fromproto6/planresourcechange.go @@ -17,7 +17,7 @@ import ( // PlanResourceChangeRequest returns the *fwserver.PlanResourceChangeRequest // equivalent of a *tfprotov6.PlanResourceChangeRequest. -func PlanResourceChangeRequest(ctx context.Context, proto6 *tfprotov6.PlanResourceChangeRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema) (*fwserver.PlanResourceChangeRequest, diag.Diagnostics) { +func PlanResourceChangeRequest(ctx context.Context, proto6 *tfprotov6.PlanResourceChangeRequest, reqResource resource.Resource, resourceSchema fwschema.Schema, providerMetaSchema fwschema.Schema, resourceBehavior resource.ResourceBehavior) (*fwserver.PlanResourceChangeRequest, diag.Diagnostics) { if proto6 == nil { return nil, nil } @@ -39,6 +39,7 @@ func PlanResourceChangeRequest(ctx context.Context, proto6 *tfprotov6.PlanResour } fw := &fwserver.PlanResourceChangeRequest{ + ResourceBehavior: resourceBehavior, ResourceSchema: resourceSchema, Resource: reqResource, ClientCapabilities: ModifyPlanClientCapabilities(proto6.ClientCapabilities), diff --git a/internal/fromproto6/planresourcechange_test.go b/internal/fromproto6/planresourcechange_test.go index e30dfe99d..81901c514 100644 --- a/internal/fromproto6/planresourcechange_test.go +++ b/internal/fromproto6/planresourcechange_test.go @@ -56,6 +56,7 @@ func TestPlanResourceChangeRequest(t *testing.T) { testCases := map[string]struct { input *tfprotov6.PlanResourceChangeRequest + resourceBehavior resource.ResourceBehavior resourceSchema fwschema.Schema resource resource.Resource providerMetaSchema fwschema.Schema @@ -241,6 +242,23 @@ func TestPlanResourceChangeRequest(t *testing.T) { ResourceSchema: testFwSchema, }, }, + "resource-behavior": { + input: &tfprotov6.PlanResourceChangeRequest{}, + resourceSchema: testFwSchema, + resourceBehavior: resource.ResourceBehavior{ + ProviderDeferred: resource.ProviderDeferredBehavior{ + EnablePlanModification: true, + }, + }, + expected: &fwserver.PlanResourceChangeRequest{ + ResourceBehavior: resource.ResourceBehavior{ + ProviderDeferred: resource.ProviderDeferredBehavior{ + EnablePlanModification: true, + }, + }, + ResourceSchema: testFwSchema, + }, + }, } for name, testCase := range testCases { @@ -249,7 +267,7 @@ func TestPlanResourceChangeRequest(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got, diags := fromproto6.PlanResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema) + got, diags := fromproto6.PlanResourceChangeRequest(context.Background(), testCase.input, testCase.resource, testCase.resourceSchema, testCase.providerMetaSchema, testCase.resourceBehavior) if diff := cmp.Diff(got, testCase.expected, cmp.AllowUnexported(privatestate.ProviderData{})); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fwserver/server.go b/internal/fwserver/server.go index 40fa631f8..5a0f90722 100644 --- a/internal/fwserver/server.go +++ b/internal/fwserver/server.go @@ -56,6 +56,11 @@ type Server struct { // access from race conditions. dataSourceTypesMutex sync.Mutex + // deferred indicates an automatic provider deferral. When this is set, + // the provider will automatically defer the PlanResourceChange, ReadResource, + // ImportResourceState, and ReadDataSource RPCs. + deferred *provider.Deferred + // functionDefinitions is the cached Function Definitions for RPCs that need to // convert data from the protocol. If not found, it will be fetched from the // Function.Definition() method. @@ -137,6 +142,19 @@ type Server struct { // resourceTypesMutex is a mutex to protect concurrent resourceTypes // access from race conditions. resourceTypesMutex sync.Mutex + + // resourceBehaviors is the cached Resource behaviors for RPCs that need to + // control framework-specific logic when interacting with a resource. + resourceBehaviors map[string]resource.ResourceBehavior + + // resourceBehaviorsDiags is the cached Diagnostics obtained while populating + // resourceBehaviors. This is to ensure any warnings or errors are also + // returned appropriately when fetching resourceBehaviors. + resourceBehaviorsDiags diag.Diagnostics + + // resourceBehaviorsMutex is a mutex to protect concurrent resourceBehaviors + // access from race conditions. + resourceBehaviorsMutex sync.Mutex } // DataSource returns the DataSource for a given type name. @@ -414,6 +432,78 @@ func (s *Server) Resource(ctx context.Context, typeName string) (resource.Resour return resourceFunc(), diags } +// ResourceBehavior returns the ResourceBehavior for a given type name. +func (s *Server) ResourceBehavior(ctx context.Context, typeName string) (resource.ResourceBehavior, diag.Diagnostics) { + resourceBehaviors, diags := s.ResourceBehaviors(ctx) + + resourceBehavior, ok := resourceBehaviors[typeName] + + if !ok { + diags.AddError( + "Resource Type Not Found", + fmt.Sprintf("No resource type named %q was found in the provider.", typeName), + ) + + return resource.ResourceBehavior{}, diags + } + + return resourceBehavior, diags +} + +// ResourceBehaviors returns a map of ResourceBehavior. The results are cached +// on first use. +func (s *Server) ResourceBehaviors(ctx context.Context) (map[string]resource.ResourceBehavior, diag.Diagnostics) { + logging.FrameworkTrace(ctx, "Checking ResourceBehaviors lock") + s.resourceBehaviorsMutex.Lock() + defer s.resourceBehaviorsMutex.Unlock() + + if s.resourceBehaviors != nil { + return s.resourceBehaviors, s.resourceBehaviorsDiags + } + + providerTypeName := s.ProviderTypeName(ctx) + s.resourceBehaviors = make(map[string]resource.ResourceBehavior) + + resourceFuncs, diags := s.ResourceFuncs(ctx) + s.resourceBehaviorsDiags.Append(diags...) + + for _, resourceFunc := range resourceFuncs { + res := resourceFunc() + + metadataRequest := resource.MetadataRequest{ + ProviderTypeName: providerTypeName, + } + metadataResponse := resource.MetadataResponse{} + + res.Metadata(ctx, metadataRequest, &metadataResponse) + + if metadataResponse.TypeName == "" { + s.resourceBehaviorsDiags.AddError( + "Resource Type Name Missing", + fmt.Sprintf("The %T Resource returned an empty string from the Metadata method. ", res)+ + "This is always an issue with the provider and should be reported to the provider developers.", + ) + continue + } + + logging.FrameworkTrace(ctx, "Found resource type", map[string]interface{}{logging.KeyResourceType: metadataResponse.TypeName}) + + if _, ok := s.resourceBehaviors[metadataResponse.TypeName]; ok { + s.resourceBehaviorsDiags.AddError( + "Duplicate Resource Type Defined", + fmt.Sprintf("The %s resource type name was returned for multiple resources. ", metadataResponse.TypeName)+ + "Resource type names must be unique. "+ + "This is always an issue with the provider and should be reported to the provider developers.", + ) + continue + } + + s.resourceBehaviors[metadataResponse.TypeName] = metadataResponse.ResourceBehavior + } + + return s.resourceBehaviors, s.resourceBehaviorsDiags +} + // ResourceFuncs returns a map of Resource functions. The results are cached // on first use. func (s *Server) ResourceFuncs(ctx context.Context) (map[string]func() resource.Resource, diag.Diagnostics) { diff --git a/internal/fwserver/server_configureprovider.go b/internal/fwserver/server_configureprovider.go index 3f841887f..2e04bc046 100644 --- a/internal/fwserver/server_configureprovider.go +++ b/internal/fwserver/server_configureprovider.go @@ -22,6 +22,19 @@ func (s *Server) ConfigureProvider(ctx context.Context, req *provider.ConfigureR logging.FrameworkTrace(ctx, "Called provider defined Provider Configure") + if resp.Deferred != nil { + if !req.ClientCapabilities.DeferralAllowed { + resp.Diagnostics.AddError("Invalid Deferred Provider Response", + "Provider configured a deferred response for all resources and data sources but the Terraform request "+ + "did not indicate support for deferred actions. This is an issue with the provider and should be reported to the provider developers.") + return + } + + logging.FrameworkDebug(ctx, "Provider has configured a deferred response, "+ + "all associated resources and data sources will automatically return a deferred response.") + } + + s.deferred = resp.Deferred s.DataSourceConfigureData = resp.DataSourceData s.ResourceConfigureData = resp.ResourceData } diff --git a/internal/fwserver/server_configureprovider_test.go b/internal/fwserver/server_configureprovider_test.go index 605fe719d..f7e29568e 100644 --- a/internal/fwserver/server_configureprovider_test.go +++ b/internal/fwserver/server_configureprovider_test.go @@ -8,6 +8,8 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" @@ -16,7 +18,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-go/tftypes" ) func TestServerConfigureProvider(t *testing.T) { @@ -56,6 +57,25 @@ func TestServerConfigureProvider(t *testing.T) { }, expectedResponse: &provider.ConfigureResponse{}, }, + "request-client-capabilities-deferral-allowed": { + 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) { + if req.ClientCapabilities.DeferralAllowed != true { + resp.Diagnostics.AddError("Unexpected req.ClientCapabilities.DeferralAllowed value", + "expected: true but got: false") + } + }, + }, + }, + request: &provider.ConfigureRequest{ + ClientCapabilities: provider.ConfigureProviderClientCapabilities{ + DeferralAllowed: true, + }, + }, + expectedResponse: &provider.ConfigureResponse{}, + }, "request-config": { server: &fwserver.Server{ Provider: &testprovider.Provider{ @@ -112,6 +132,28 @@ func TestServerConfigureProvider(t *testing.T) { DataSourceData: "test-provider-configure-value", }, }, + "response-deferral": { + 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} + resp.DataSourceData = "test-provider-configure-value" + }, + }, + }, + request: &provider.ConfigureRequest{ + ClientCapabilities: provider.ConfigureProviderClientCapabilities{ + DeferralAllowed: true, + }, + }, + expectedResponse: &provider.ConfigureResponse{ + Deferred: &provider.Deferred{ + Reason: provider.DeferredReasonProviderConfigUnknown, + }, + DataSourceData: "test-provider-configure-value", + }, + }, "response-diagnostics": { server: &fwserver.Server{ Provider: &testprovider.Provider{ @@ -136,6 +178,27 @@ func TestServerConfigureProvider(t *testing.T) { }, }, }, + "response-invalid-deferral-diagnostic": { + 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} + }, + }, + }, + request: &provider.ConfigureRequest{}, + expectedResponse: &provider.ConfigureResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic("Invalid Deferred Provider Response", + "Provider configured a deferred response for all resources and data sources but the Terraform request "+ + "did not indicate support for deferred actions. This is an issue with the provider and should be reported to the provider developers."), + }, + Deferred: &provider.Deferred{ + Reason: provider.DeferredReasonProviderConfigUnknown, + }, + }, + }, "response-resourcedata": { server: &fwserver.Server{ Provider: &testprovider.Provider{ diff --git a/internal/fwserver/server_importresourcestate.go b/internal/fwserver/server_importresourcestate.go index 22d8da5a1..7288cc3e7 100644 --- a/internal/fwserver/server_importresourcestate.go +++ b/internal/fwserver/server_importresourcestate.go @@ -6,6 +6,8 @@ package fwserver import ( "context" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" @@ -53,6 +55,29 @@ func (s *Server) ImportResourceState(ctx context.Context, req *ImportResourceSta return } + 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(), + }, + ) + // Send an unknown value for the imported object + resp.ImportedResources = []ImportedResource{ + { + State: tfsdk.State{ + Raw: tftypes.NewValue(req.EmptyState.Schema.Type().TerraformType(ctx), tftypes.UnknownValue), + Schema: req.EmptyState.Schema, + }, + TypeName: req.TypeName, + Private: &privatestate.Data{}, + }, + } + resp.Deferred = &resource.Deferred{ + Reason: resource.DeferredReason(s.deferred.Reason), + } + return + } + if resourceWithConfigure, ok := req.Resource.(resource.ResourceWithConfigure); ok { logging.FrameworkTrace(ctx, "Resource implements ResourceWithConfigure") diff --git a/internal/fwserver/server_importresourcestate_test.go b/internal/fwserver/server_importresourcestate_test.go index 26eca6913..bd0275a12 100644 --- a/internal/fwserver/server_importresourcestate_test.go +++ b/internal/fwserver/server_importresourcestate_test.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" @@ -38,6 +39,8 @@ func TestServerImportResourceState(t *testing.T) { "required": tftypes.NewValue(tftypes.String, nil), }) + testUnknownStateValue := tftypes.NewValue(testType, tftypes.UnknownValue) + testStateValue := tftypes.NewValue(testType, map[string]tftypes.Value{ "id": tftypes.NewValue(tftypes.String, "test-id"), "optional": tftypes.NewValue(tftypes.String, nil), @@ -63,6 +66,11 @@ func TestServerImportResourceState(t *testing.T) { Schema: testSchema, } + testUnknownState := &tfsdk.State{ + Raw: testUnknownStateValue, + Schema: testSchema, + } + testState := &tfsdk.State{ Raw: testStateValue, Schema: testSchema, @@ -89,9 +97,10 @@ func TestServerImportResourceState(t *testing.T) { } testCases := map[string]struct { - server *fwserver.Server - request *fwserver.ImportResourceStateRequest - expectedResponse *fwserver.ImportResourceStateResponse + server *fwserver.Server + request *fwserver.ImportResourceStateRequest + expectedResponse *fwserver.ImportResourceStateResponse + configureProviderReq *provider.ConfigureRequest }{ "nil": { server: &fwserver.Server{ @@ -280,7 +289,44 @@ func TestServerImportResourceState(t *testing.T) { }, }, }, - "response-importedresources-deferral": { + "response-importedresources-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.ImportResourceStateRequest{ + EmptyState: *testEmptyState, + ID: "test-id", + Resource: &testprovider.ResourceWithImportState{ + Resource: &testprovider.Resource{}, + ImportStateMethod: func(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resp.Diagnostics.AddError("Test assertion failed: ", "import shouldn't be called") + }, + }, + TypeName: "test_resource", + ClientCapabilities: testDeferral, + }, + expectedResponse: &fwserver.ImportResourceStateResponse{ + ImportedResources: []fwserver.ImportedResource{ + { + State: *testUnknownState, + TypeName: "test_resource", + Private: &privatestate.Data{}, + }, + }, + Deferred: &resource.Deferred{Reason: resource.DeferredReasonProviderConfigUnknown}, + }, + }, + "response-importedresources-deferral-manual": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, }, @@ -378,6 +424,11 @@ func TestServerImportResourceState(t *testing.T) { 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.ImportResourceStateResponse{} testCase.server.ImportResourceState(context.Background(), testCase.request, response) diff --git a/internal/fwserver/server_planresourcechange.go b/internal/fwserver/server_planresourcechange.go index 3a0acb6ca..fc7413e38 100644 --- a/internal/fwserver/server_planresourcechange.go +++ b/internal/fwserver/server_planresourcechange.go @@ -34,6 +34,7 @@ type PlanResourceChangeRequest struct { ProviderMeta *tfsdk.Config ResourceSchema fwschema.Schema Resource resource.Resource + ResourceBehavior resource.ResourceBehavior } // PlanResourceChangeResponse is the framework server response for the @@ -52,6 +53,24 @@ func (s *Server) PlanResourceChange(ctx context.Context, req *PlanResourceChange return } + // Skip ModifyPlan for automatic deferrals with proposed new state as a best effort for PlannedState + // unless ProviderDeferredBehavior.EnablePlanModification is true. + if s.deferred != nil && !req.ResourceBehavior.ProviderDeferred.EnablePlanModification { + logging.FrameworkDebug(ctx, "Provider has deferred response configured, automatically returning deferred response.", + map[string]interface{}{ + logging.KeyDeferredReason: s.deferred.Reason.String(), + }, + ) + + resp.PlannedState = planToState(*req.ProposedNewState) + resp.PlannedPrivate = req.PriorPrivate + resp.Deferred = &resource.Deferred{ + Reason: resource.DeferredReason(s.deferred.Reason), + } + + return + } + if resourceWithConfigure, ok := req.Resource.(resource.ResourceWithConfigure); ok { logging.FrameworkTrace(ctx, "Resource implements ResourceWithConfigure") @@ -289,6 +308,22 @@ func (s *Server) PlanResourceChange(ctx context.Context, req *PlanResourceChange resp.RequiresReplace = append(resp.RequiresReplace, modifyPlanResp.RequiresReplace...) resp.PlannedPrivate.Provider = modifyPlanResp.Private resp.Deferred = modifyPlanResp.Deferred + + // Provider deferred response is present, add the deferred response alongside the provider-modified plan + if s.deferred != nil { + logging.FrameworkDebug(ctx, "Provider has deferred response configured, returning deferred response with modified plan.") + // Only set the response to the provider configured deferred reason if there is no resource configured deferred reason + if resp.Deferred == nil { + resp.Deferred = &resource.Deferred{ + Reason: resource.DeferredReason(s.deferred.Reason), + } + } else { + logging.FrameworkDebug(ctx, fmt.Sprintf("Resource has deferred reason configured, "+ + "replacing provider deferred reason: %s with resource deferred reason: %s", + s.deferred.Reason.String(), modifyPlanResp.Deferred.Reason.String())) + } + return + } } // Ensure deterministic RequiresReplace by sorting and deduplicating diff --git a/internal/fwserver/server_planresourcechange_test.go b/internal/fwserver/server_planresourcechange_test.go index 1a60eda38..565fe5370 100644 --- a/internal/fwserver/server_planresourcechange_test.go +++ b/internal/fwserver/server_planresourcechange_test.go @@ -21,6 +21,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/metaschema" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" @@ -1235,9 +1236,10 @@ func TestServerPlanResourceChange(t *testing.T) { } testCases := map[string]struct { - server *fwserver.Server - request *fwserver.PlanResourceChangeRequest - expectedResponse *fwserver.PlanResourceChangeResponse + server *fwserver.Server + request *fwserver.PlanResourceChangeRequest + expectedResponse *fwserver.PlanResourceChangeResponse + configureProviderReq *provider.ConfigureRequest }{ "resource-configure-data": { server: &fwserver.Server{ @@ -3007,7 +3009,180 @@ func TestServerPlanResourceChange(t *testing.T) { PlannedPrivate: testEmptyPrivate, }, }, - "create-resourcewithmodifyplan-response-deferral": { + "create-resourcewithmodifyplan-response-deferral-automatic-override-provider-deferral-reason": { + 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.PlanResourceChangeRequest{ + ClientCapabilities: testDeferralAllowed, + ResourceBehavior: resource.ResourceBehavior{ + ProviderDeferred: resource.ProviderDeferredBehavior{ + EnablePlanModification: true, + }, + }, + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + ProposedNewState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PriorState: testEmptyState, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + if req.ClientCapabilities.DeferralAllowed == true { + resp.Deferred = &resource.Deferred{Reason: resource.DeferredReasonAbsentPrereq} + } + + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + data.TestComputed = types.StringValue("test-plannedstate-value") + + resp.Diagnostics.Append(resp.Plan.Set(ctx, &data)...) + }, + }, + }, + expectedResponse: &fwserver.PlanResourceChangeResponse{ + Deferred: &resource.Deferred{Reason: resource.DeferredReasonAbsentPrereq}, + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PlannedPrivate: testEmptyPrivate, + }, + }, + "create-resourcewithmodifyplan-response-deferral-automatic-plan-modification": { + 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.PlanResourceChangeRequest{ + ResourceBehavior: resource.ResourceBehavior{ + ProviderDeferred: resource.ProviderDeferredBehavior{ + EnablePlanModification: true, + }, + }, + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + ProposedNewState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PriorState: testEmptyState, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + var data testSchemaData + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + data.TestComputed = types.StringValue("test-plannedstate-value") + + resp.Diagnostics.Append(resp.Plan.Set(ctx, &data)...) + }, + }, + }, + expectedResponse: &fwserver.PlanResourceChangeResponse{ + Deferred: &resource.Deferred{Reason: resource.DeferredReasonProviderConfigUnknown}, + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, "test-plannedstate-value"), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PlannedPrivate: testEmptyPrivate, + }, + }, + "create-resourcewithmodifyplan-response-deferral-automatic-skip-plan-modification": { + 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.PlanResourceChangeRequest{ + Config: &tfsdk.Config{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + ProposedNewState: &tfsdk.Plan{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + PriorState: testEmptyState, + ResourceSchema: testSchema, + Resource: &testprovider.ResourceWithModifyPlan{ + ModifyPlanMethod: func(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + resp.Diagnostics.AddError("Test assertion failed: ", "modifyplan shouldn't be called") + }, + }, + }, + expectedResponse: &fwserver.PlanResourceChangeResponse{ + Deferred: &resource.Deferred{Reason: resource.DeferredReasonProviderConfigUnknown}, + PlannedState: &tfsdk.State{ + Raw: tftypes.NewValue(testSchemaType, map[string]tftypes.Value{ + "test_computed": tftypes.NewValue(tftypes.String, nil), + "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), + }), + Schema: testSchema, + }, + }, + }, + "create-resourcewithmodifyplan-response-deferral-manual": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, }, @@ -6113,6 +6288,11 @@ func TestServerPlanResourceChange(t *testing.T) { 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.PlanResourceChangeResponse{} testCase.server.PlanResourceChange(context.Background(), testCase.request, response) diff --git a/internal/fwserver/server_readdatasource.go b/internal/fwserver/server_readdatasource.go index ee0c4fb07..173282321 100644 --- a/internal/fwserver/server_readdatasource.go +++ b/internal/fwserver/server_readdatasource.go @@ -6,6 +6,8 @@ package fwserver import ( "context" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" @@ -38,6 +40,25 @@ func (s *Server) ReadDataSource(ctx context.Context, req *ReadDataSourceRequest, return } + 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(), + }, + ) + // Send an unknown value for the data source. This will replace any configured values + // for ease of implementation as Terraform Core currently does not use these values for + // deferred actions, but this design could change in the future. + resp.State = &tfsdk.State{ + Raw: tftypes.NewValue(req.DataSourceSchema.Type().TerraformType(ctx), tftypes.UnknownValue), + Schema: req.DataSourceSchema, + } + resp.Deferred = &datasource.Deferred{ + Reason: datasource.DeferredReason(s.deferred.Reason), + } + return + } + if dataSourceWithConfigure, ok := req.DataSource.(datasource.DataSourceWithConfigure); ok { logging.FrameworkTrace(ctx, "DataSource implements DataSourceWithConfigure") diff --git a/internal/fwserver/server_readdatasource_test.go b/internal/fwserver/server_readdatasource_test.go index 2a7a645e4..d2f91bd37 100644 --- a/internal/fwserver/server_readdatasource_test.go +++ b/internal/fwserver/server_readdatasource_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -41,6 +42,8 @@ func TestServerReadDataSource(t *testing.T) { "test_required": tftypes.NewValue(tftypes.String, "test-config-value"), }) + testStateUnknownValue := tftypes.NewValue(testType, tftypes.UnknownValue) + testSchema := schema.Schema{ Attributes: map[string]schema.Attribute{ "test_computed": schema.StringAttribute{ @@ -94,6 +97,11 @@ func TestServerReadDataSource(t *testing.T) { Schema: testSchema, } + testStateUnknown := &tfsdk.State{ + Raw: testStateUnknownValue, + Schema: testSchema, + } + testState := &tfsdk.State{ Raw: testStateValue, Schema: testSchema, @@ -104,9 +112,10 @@ func TestServerReadDataSource(t *testing.T) { } testCases := map[string]struct { - server *fwserver.Server - request *fwserver.ReadDataSourceRequest - expectedResponse *fwserver.ReadDataSourceResponse + server *fwserver.Server + request *fwserver.ReadDataSourceRequest + expectedResponse *fwserver.ReadDataSourceResponse + configureProviderReq *provider.ConfigureRequest }{ "nil": { server: &fwserver.Server{ @@ -236,7 +245,36 @@ func TestServerReadDataSource(t *testing.T) { State: testStateUnchanged, }, }, - "response-deferral": { + "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.ReadDataSourceRequest{ + Config: testConfig, + DataSourceSchema: testSchema, + DataSource: &testprovider.DataSource{ + ReadMethod: func(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + resp.Diagnostics.AddError("Test assertion failed: ", "read shouldn't be called") + }, + }, + ClientCapabilities: testDeferralAllowed, + }, + expectedResponse: &fwserver.ReadDataSourceResponse{ + State: testStateUnknown, + Deferred: &datasource.Deferred{Reason: datasource.DeferredReasonProviderConfigUnknown}, + }, + }, + "response-deferral-manual": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, }, @@ -420,6 +458,11 @@ func TestServerReadDataSource(t *testing.T) { 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.ReadDataSourceResponse{} testCase.server.ReadDataSource(context.Background(), testCase.request, response) diff --git a/internal/fwserver/server_readresource.go b/internal/fwserver/server_readresource.go index 2ec8e6cc7..628a2e445 100644 --- a/internal/fwserver/server_readresource.go +++ b/internal/fwserver/server_readresource.go @@ -49,6 +49,19 @@ func (s *Server) ReadResource(ctx context.Context, req *ReadResourceRequest, res return } + 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.NewState = req.CurrentState + resp.Deferred = &resource.Deferred{ + Reason: resource.DeferredReason(s.deferred.Reason), + } + return + } + if resourceWithConfigure, ok := req.Resource.(resource.ResourceWithConfigure); ok { logging.FrameworkTrace(ctx, "Resource implements ResourceWithConfigure") diff --git a/internal/fwserver/server_readresource_test.go b/internal/fwserver/server_readresource_test.go index 964de02dc..a9520edd4 100644 --- a/internal/fwserver/server_readresource_test.go +++ b/internal/fwserver/server_readresource_test.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/tfsdk" @@ -140,9 +141,10 @@ func TestServerReadResource(t *testing.T) { } testCases := map[string]struct { - server *fwserver.Server - request *fwserver.ReadResourceRequest - expectedResponse *fwserver.ReadResourceResponse + server *fwserver.Server + request *fwserver.ReadResourceRequest + expectedResponse *fwserver.ReadResourceResponse + configureProviderReq *provider.ConfigureRequest }{ "nil": { server: &fwserver.Server{ @@ -339,7 +341,35 @@ func TestServerReadResource(t *testing.T) { Private: testEmptyPrivate, }, }, - "response-deferral": { + "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.ReadResourceRequest{ + CurrentState: testCurrentState, + Resource: &testprovider.Resource{ + ReadMethod: func(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + resp.Diagnostics.AddError("Test assertion failed: ", "read shouldn't be called") + }, + }, + ClientCapabilities: testDeferralAllowed, + }, + expectedResponse: &fwserver.ReadResourceResponse{ + NewState: testCurrentState, + Deferred: &resource.Deferred{Reason: resource.DeferredReasonProviderConfigUnknown}, + }, + }, + "response-deferral-manual": { server: &fwserver.Server{ Provider: &testprovider.Provider{}, }, @@ -579,6 +609,11 @@ func TestServerReadResource(t *testing.T) { 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.ReadResourceResponse{} testCase.server.ReadResource(context.Background(), testCase.request, response) diff --git a/internal/logging/keys.go b/internal/logging/keys.go index 7c68d0f13..312a839ac 100644 --- a/internal/logging/keys.go +++ b/internal/logging/keys.go @@ -18,6 +18,9 @@ const ( // The type of data source being operated on, such as "archive_file" KeyDataSourceType = "tf_data_source_type" + // The Deferred reason for an RPC response + KeyDeferredReason = "tf_deferred_reason" + // Human readable string when calling a provider defined type that must // implement the Description() method, such as validators. KeyDescription = "description" diff --git a/internal/proto5server/server_configureprovider.go b/internal/proto5server/server_configureprovider.go index 3f5d22e37..ef33f39e8 100644 --- a/internal/proto5server/server_configureprovider.go +++ b/internal/proto5server/server_configureprovider.go @@ -6,11 +6,12 @@ package proto5server import ( "context" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" "github.com/hashicorp/terraform-plugin-framework/provider" - "github.com/hashicorp/terraform-plugin-go/tfprotov5" ) // ConfigureProvider satisfies the tfprotov5.ProviderServer interface. diff --git a/internal/proto5server/server_planresourcechange.go b/internal/proto5server/server_planresourcechange.go index 6cc995daa..a5cec1987 100644 --- a/internal/proto5server/server_planresourcechange.go +++ b/internal/proto5server/server_planresourcechange.go @@ -6,11 +6,12 @@ package proto5server import ( "context" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto5" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" - "github.com/hashicorp/terraform-plugin-go/tfprotov5" ) // PlanResourceChange satisfies the tfprotov5.ProviderServer interface. @@ -44,7 +45,15 @@ func (s *Server) PlanResourceChange(ctx context.Context, proto5Req *tfprotov5.Pl return toproto5.PlanResourceChangeResponse(ctx, fwResp), nil } - fwReq, diags := fromproto5.PlanResourceChangeRequest(ctx, proto5Req, resource, resourceSchema, providerMetaSchema) + resourceBehavior, diags := s.FrameworkServer.ResourceBehavior(ctx, proto5Req.TypeName) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto5.PlanResourceChangeResponse(ctx, fwResp), nil + } + + fwReq, diags := fromproto5.PlanResourceChangeRequest(ctx, proto5Req, resource, resourceSchema, providerMetaSchema, resourceBehavior) fwResp.Diagnostics.Append(diags...) diff --git a/internal/proto5server/server_planresourcechange_test.go b/internal/proto5server/server_planresourcechange_test.go index 1db515b8f..ad16683fb 100644 --- a/internal/proto5server/server_planresourcechange_test.go +++ b/internal/proto5server/server_planresourcechange_test.go @@ -8,6 +8,9 @@ import ( "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/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testprovider" "github.com/hashicorp/terraform-plugin-framework/path" @@ -16,8 +19,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-go/tfprotov5" - "github.com/hashicorp/terraform-plugin-go/tftypes" ) func TestServerPlanResourceChange(t *testing.T) { diff --git a/internal/proto6server/server_planresourcechange.go b/internal/proto6server/server_planresourcechange.go index 9782fc04e..32d13ddd6 100644 --- a/internal/proto6server/server_planresourcechange.go +++ b/internal/proto6server/server_planresourcechange.go @@ -6,11 +6,12 @@ package proto6server import ( "context" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-framework/internal/fromproto6" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" - "github.com/hashicorp/terraform-plugin-go/tfprotov6" ) // PlanResourceChange satisfies the tfprotov6.ProviderServer interface. @@ -44,7 +45,15 @@ func (s *Server) PlanResourceChange(ctx context.Context, proto6Req *tfprotov6.Pl return toproto6.PlanResourceChangeResponse(ctx, fwResp), nil } - fwReq, diags := fromproto6.PlanResourceChangeRequest(ctx, proto6Req, resource, resourceSchema, providerMetaSchema) + resourceBehavior, diags := s.FrameworkServer.ResourceBehavior(ctx, proto6Req.TypeName) + + fwResp.Diagnostics.Append(diags...) + + if fwResp.Diagnostics.HasError() { + return toproto6.PlanResourceChangeResponse(ctx, fwResp), nil + } + + fwReq, diags := fromproto6.PlanResourceChangeRequest(ctx, proto6Req, resource, resourceSchema, providerMetaSchema, resourceBehavior) fwResp.Diagnostics.Append(diags...) diff --git a/provider/configure.go b/provider/configure.go index 600f402ff..9b6678bf7 100644 --- a/provider/configure.go +++ b/provider/configure.go @@ -8,6 +8,18 @@ import ( "github.com/hashicorp/terraform-plugin-framework/tfsdk" ) +// ConfigureProviderClientCapabilities allows Terraform to publish information +// regarding optionally supported protocol features for the ConfigureProvider RPC, +// such as forward-compatible Terraform behavior changes. +type ConfigureProviderClientCapabilities 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 +} + // ConfigureRequest represents a request containing the values the user // specified for the provider configuration block, along with other runtime // information from Terraform or the Plugin SDK. An instance of this request @@ -24,6 +36,10 @@ type ConfigureRequest struct { // that's implementing the Provider interface, for use in later // resource CRUD operations. Config tfsdk.Config + + // ClientCapabilities defines optionally supported protocol features for the + // ConfigureProvider RPC, such as forward-compatible Terraform behavior changes. + ClientCapabilities ConfigureProviderClientCapabilities } // ConfigureResponse represents a response to a @@ -45,4 +61,14 @@ type ConfigureResponse struct { // to [resource.ConfigureRequest.ProviderData] for each Resource type // that implements the Configure method. ResourceData any + + // Deferred indicates that Terraform should automatically defer + // all resources and data sources for this provider. + // + // This field can only be set if + // `(provider.ConfigureRequest).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/provider/deferred.go b/provider/deferred.go new file mode 100644 index 000000000..5c5fceb5d --- /dev/null +++ b/provider/deferred.go @@ -0,0 +1,43 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +// MAINTAINER NOTE: The deferred reason at enum value 1 in the plugin-protocol +// is not relevant for provider-level automatic deferred responses. +// provider.DeferredReason is directly mapped to the plugin-protocol which is +// why enum value 1 is skipped here +const ( + // DeferredReasonUnknown is used to indicate an invalid `DeferredReason`. + // Provider developers should not use it. + DeferredReasonUnknown DeferredReason = 0 + + // 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 +) + +// 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 2: + return "Provider Config Unknown" + } + return "Unknown" +} diff --git a/resource/metadata.go b/resource/metadata.go index 9750a46cb..289cd6ab2 100644 --- a/resource/metadata.go +++ b/resource/metadata.go @@ -21,4 +21,32 @@ type MetadataResponse struct { // TypeName should be the full resource type, including the provider // type prefix and an underscore. For example, examplecloud_thing. TypeName string + + // ResourceBehavior is used to control framework-specific logic when + // interacting with this resource. + ResourceBehavior ResourceBehavior +} + +// ResourceBehavior controls framework-specific logic when interacting +// with a resource. +type ResourceBehavior struct { + // ProviderDeferred enables provider-defined logic to be executed + // in the case of an automatic deferred response from provider configure. + // + // 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. + ProviderDeferred ProviderDeferredBehavior +} + +// ProviderDeferredBehavior enables provider-defined logic to be executed +// in the case of a deferred response from provider configuration. +// +// 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 ProviderDeferredBehavior struct { + // When EnablePlanModification is true, framework will still execute + // provider-defined resource plan modification logic if + // provider.Configure defers. Framework will then automatically return a + // deferred response along with the modified plan. + EnablePlanModification bool }