diff --git a/go.mod b/go.mod index 4ea3af6..b6fd03e 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.23.7 require ( github.com/google/go-cmp v0.7.0 - github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250616135123-a19df43120ea + github.com/hashicorp/terraform-plugin-go v0.29.0-alpha.1.0.20250709165734-a8477a15f806 github.com/hashicorp/terraform-plugin-log v0.9.0 google.golang.org/grpc v1.73.0 ) diff --git a/go.sum b/go.sum index 33da598..58d2914 100644 --- a/go.sum +++ b/go.sum @@ -21,8 +21,8 @@ github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0U github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250616135123-a19df43120ea h1:U9EAAeQtszGlR7mDS7rY77B/a4/XiMDB8HfAtqLAuAQ= -github.com/hashicorp/terraform-plugin-go v0.28.1-0.20250616135123-a19df43120ea/go.mod h1:hL//wLEfYo0YVt0TC/VLzia/ADQQto3HEm4/jX2gkdY= +github.com/hashicorp/terraform-plugin-go v0.29.0-alpha.1.0.20250709165734-a8477a15f806 h1:i3kA1sT/Fk8Ex+VVKdjf9sFOPwS7w3Q73pfbnxKwdjg= +github.com/hashicorp/terraform-plugin-go v0.29.0-alpha.1.0.20250709165734-a8477a15f806/go.mod h1:hL//wLEfYo0YVt0TC/VLzia/ADQQto3HEm4/jX2gkdY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-registry-address v0.3.0 h1:HMpK3nqaGFPS9VmgRXrJL/dzHNdheGVKk5k7VlFxzCo= diff --git a/internal/tf5testserver/tf5testserver.go b/internal/tf5testserver/tf5testserver.go index b724b06..fe90817 100644 --- a/internal/tf5testserver/tf5testserver.go +++ b/internal/tf5testserver/tf5testserver.go @@ -68,6 +68,10 @@ type TestServer struct { ValidateListResourceConfigCalled map[string]bool ListResourceCalled map[string]bool + + PlanActionCalled map[string]bool + + InvokeActionCalled map[string]bool } func (s *TestServer) ProviderServer() tfprotov5.ProviderServer { @@ -295,3 +299,21 @@ func (s *TestServer) ListResource(_ context.Context, req *tfprotov5.ListResource s.ListResourceCalled[req.TypeName] = true return nil, nil } + +func (s *TestServer) PlanAction(ctx context.Context, req *tfprotov5.PlanActionRequest) (*tfprotov5.PlanActionResponse, error) { + if s.PlanActionCalled == nil { + s.PlanActionCalled = make(map[string]bool) + } + + s.PlanActionCalled[req.ActionType] = true + return nil, nil +} + +func (s *TestServer) InvokeAction(ctx context.Context, req *tfprotov5.InvokeActionRequest) (*tfprotov5.InvokeActionServerStream, error) { + if s.InvokeActionCalled == nil { + s.InvokeActionCalled = make(map[string]bool) + } + + s.InvokeActionCalled[req.ActionType] = true + return nil, nil +} diff --git a/internal/tf6testserver/tf6testserver.go b/internal/tf6testserver/tf6testserver.go index dfdd79d..fe61b07 100644 --- a/internal/tf6testserver/tf6testserver.go +++ b/internal/tf6testserver/tf6testserver.go @@ -68,6 +68,10 @@ type TestServer struct { ValidateListResourceConfigCalled map[string]bool ListResourceCalled map[string]bool + + PlanActionCalled map[string]bool + + InvokeActionCalled map[string]bool } func (s *TestServer) ProviderServer() tfprotov6.ProviderServer { @@ -295,3 +299,21 @@ func (s *TestServer) ListResource(_ context.Context, req *tfprotov6.ListResource s.ListResourceCalled[req.TypeName] = true return nil, nil } + +func (s *TestServer) PlanAction(ctx context.Context, req *tfprotov6.PlanActionRequest) (*tfprotov6.PlanActionResponse, error) { + if s.PlanActionCalled == nil { + s.PlanActionCalled = make(map[string]bool) + } + + s.PlanActionCalled[req.ActionType] = true + return nil, nil +} + +func (s *TestServer) InvokeAction(ctx context.Context, req *tfprotov6.InvokeActionRequest) (*tfprotov6.InvokeActionServerStream, error) { + if s.InvokeActionCalled == nil { + s.InvokeActionCalled = make(map[string]bool) + } + + s.InvokeActionCalled[req.ActionType] = true + return nil, nil +} diff --git a/internal/tfprotov5tov6/tfprotov5tov6.go b/internal/tfprotov5tov6/tfprotov5tov6.go index 69ec934..28c7f42 100644 --- a/internal/tfprotov5tov6/tfprotov5tov6.go +++ b/internal/tfprotov5tov6/tfprotov5tov6.go @@ -4,6 +4,8 @@ package tfprotov5tov6 import ( + "fmt" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tfprotov6" ) @@ -301,6 +303,7 @@ func GetMetadataResponse(in *tfprotov5.GetMetadataResponse) *tfprotov6.GetMetada } resp := &tfprotov6.GetMetadataResponse{ + Actions: make([]tfprotov6.ActionMetadata, 0, len(in.Actions)), DataSources: make([]tfprotov6.DataSourceMetadata, 0, len(in.DataSources)), Diagnostics: Diagnostics(in.Diagnostics), EphemeralResources: make([]tfprotov6.EphemeralResourceMetadata, 0, len(in.Resources)), @@ -330,6 +333,10 @@ func GetMetadataResponse(in *tfprotov5.GetMetadataResponse) *tfprotov6.GetMetada resp.Resources = append(resp.Resources, ResourceMetadata(resource)) } + for _, action := range in.Actions { + resp.Actions = append(resp.Actions, ActionMetadata(action)) + } + return resp } @@ -376,7 +383,14 @@ func GetProviderSchemaResponse(in *tfprotov5.GetProviderSchemaResponse) *tfproto resourceSchemas[k] = Schema(v) } + actionSchemas := make(map[string]*tfprotov6.ActionSchema, len(in.ActionSchemas)) + + for k, v := range in.ActionSchemas { + actionSchemas[k] = ActionSchema(v) + } + return &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: actionSchemas, DataSourceSchemas: dataSourceSchemas, Diagnostics: Diagnostics(in.Diagnostics), EphemeralResourceSchemas: ephemeralResourceSchemas, @@ -1052,3 +1066,244 @@ func ListResourceResult(in tfprotov5.ListResourceResult) tfprotov6.ListResourceR Diagnostics: Diagnostics(in.Diagnostics), } } + +func ActionMetadata(in tfprotov5.ActionMetadata) tfprotov6.ActionMetadata { + return tfprotov6.ActionMetadata{ + TypeName: in.TypeName, + } +} + +func ActionSchema(in *tfprotov5.ActionSchema) *tfprotov6.ActionSchema { + if in == nil { + return nil + } + + actionSchema := &tfprotov6.ActionSchema{ + Schema: Schema(in.Schema), + } + + switch actionSchemaType := in.Type.(type) { + case tfprotov5.UnlinkedActionSchemaType: + actionSchema.Type = tfprotov6.UnlinkedActionSchemaType{} + case tfprotov5.LifecycleActionSchemaType: + actionSchema.Type = tfprotov6.LifecycleActionSchemaType{ + Executes: tfprotov6.LifecycleExecutionOrder(actionSchemaType.Executes), + LinkedResource: LinkedResourceSchema(actionSchemaType.LinkedResource), + } + case tfprotov5.LinkedActionSchemaType: + actionSchema.Type = tfprotov6.LinkedActionSchemaType{ + LinkedResources: LinkedResourceSchemas(actionSchemaType.LinkedResources), + } + default: + // It is not currently possible to create tfprotov5.ActionSchemaType + // implementations outside the terraform-plugin-go module. If this panic was reached, + // it implies that a new event type was introduced and needs to be implemented + // as a new case above. + panic(fmt.Sprintf("unimplemented tfprotov5.ActionSchemaType type: %T", in.Type)) + } + + return actionSchema +} + +func LinkedResourceSchemas(in []*tfprotov5.LinkedResourceSchema) []*tfprotov6.LinkedResourceSchema { + schemas := make([]*tfprotov6.LinkedResourceSchema, 0, len(in)) + + for _, schema := range in { + schemas = append(schemas, LinkedResourceSchema(schema)) + } + + return schemas +} + +func LinkedResourceSchema(in *tfprotov5.LinkedResourceSchema) *tfprotov6.LinkedResourceSchema { + if in == nil { + return nil + } + + return &tfprotov6.LinkedResourceSchema{ + TypeName: in.TypeName, + Description: in.Description, + } +} + +func PlanActionRequest(in *tfprotov5.PlanActionRequest) *tfprotov6.PlanActionRequest { + if in == nil { + return nil + } + + return &tfprotov6.PlanActionRequest{ + ActionType: in.ActionType, + LinkedResources: ProposedLinkedResources(in.LinkedResources), + Config: DynamicValue(in.Config), + ClientCapabilities: PlanActionClientCapabilities(in.ClientCapabilities), + } +} + +func ProposedLinkedResources(in []*tfprotov5.ProposedLinkedResource) []*tfprotov6.ProposedLinkedResource { + if in == nil { + return nil + } + + linkedResources := make([]*tfprotov6.ProposedLinkedResource, 0, len(in)) + + for _, inLinkedResource := range in { + if inLinkedResource == nil { + linkedResources = append(linkedResources, nil) + continue + } + + linkedResources = append(linkedResources, &tfprotov6.ProposedLinkedResource{ + PriorState: DynamicValue(inLinkedResource.PriorState), + PlannedState: DynamicValue(inLinkedResource.PlannedState), + Config: DynamicValue(inLinkedResource.Config), + PriorIdentity: ResourceIdentityData(inLinkedResource.PriorIdentity), + }) + } + + return linkedResources +} + +func PlanActionClientCapabilities(in *tfprotov5.PlanActionClientCapabilities) *tfprotov6.PlanActionClientCapabilities { + if in == nil { + return nil + } + + resp := &tfprotov6.PlanActionClientCapabilities{ + DeferralAllowed: in.DeferralAllowed, + } + + return resp +} + +func PlanActionResponse(in *tfprotov5.PlanActionResponse) *tfprotov6.PlanActionResponse { + if in == nil { + return nil + } + + return &tfprotov6.PlanActionResponse{ + LinkedResources: PlannedLinkedResources(in.LinkedResources), + Diagnostics: Diagnostics(in.Diagnostics), + Deferred: Deferred(in.Deferred), + } +} + +func PlannedLinkedResources(in []*tfprotov5.PlannedLinkedResource) []*tfprotov6.PlannedLinkedResource { + if in == nil { + return nil + } + + linkedResources := make([]*tfprotov6.PlannedLinkedResource, 0, len(in)) + + for _, inLinkedResource := range in { + if inLinkedResource == nil { + linkedResources = append(linkedResources, nil) + continue + } + + linkedResources = append(linkedResources, &tfprotov6.PlannedLinkedResource{ + PlannedState: DynamicValue(inLinkedResource.PlannedState), + PlannedIdentity: ResourceIdentityData(inLinkedResource.PlannedIdentity), + }) + } + + return linkedResources +} + +func InvokeActionRequest(in *tfprotov5.InvokeActionRequest) *tfprotov6.InvokeActionRequest { + if in == nil { + return nil + } + + return &tfprotov6.InvokeActionRequest{ + ActionType: in.ActionType, + LinkedResources: InvokeLinkedResources(in.LinkedResources), + Config: DynamicValue(in.Config), + } +} + +func InvokeLinkedResources(in []*tfprotov5.InvokeLinkedResource) []*tfprotov6.InvokeLinkedResource { + if in == nil { + return nil + } + + linkedResources := make([]*tfprotov6.InvokeLinkedResource, 0, len(in)) + + for _, inLinkedResource := range in { + if inLinkedResource == nil { + linkedResources = append(linkedResources, nil) + continue + } + + linkedResources = append(linkedResources, &tfprotov6.InvokeLinkedResource{ + PriorState: DynamicValue(inLinkedResource.PriorState), + PlannedState: DynamicValue(inLinkedResource.PlannedState), + Config: DynamicValue(inLinkedResource.Config), + PlannedIdentity: ResourceIdentityData(inLinkedResource.PlannedIdentity), + }) + } + + return linkedResources +} + +func InvokeActionServerStream(in *tfprotov5.InvokeActionServerStream) *tfprotov6.InvokeActionServerStream { + if in == nil { + return nil + } + + return &tfprotov6.InvokeActionServerStream{ + Events: func(yield func(tfprotov6.InvokeActionEvent) bool) { + for res := range in.Events { + if !yield(InvokeActionEvent(res)) { + break + } + } + }, + } +} + +func InvokeActionEvent(in tfprotov5.InvokeActionEvent) tfprotov6.InvokeActionEvent { + switch event := (in.Type).(type) { + case tfprotov5.ProgressInvokeActionEventType: + return tfprotov6.InvokeActionEvent{ + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: event.Message, + }, + } + case tfprotov5.CompletedInvokeActionEventType: + return tfprotov6.InvokeActionEvent{ + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: NewLinkedResources(event.LinkedResources), + Diagnostics: Diagnostics(event.Diagnostics), + }, + } + } + + // It is not currently possible to create tfprotov5.InvokeActionEventType + // implementations outside the terraform-plugin-go module. If this panic was reached, + // it implies that a new event type was introduced and needs to be implemented + // as a new case above. + panic(fmt.Sprintf("unimplemented tfprotov5.InvokeActionEventType type: %T", in.Type)) +} + +func NewLinkedResources(in []*tfprotov5.NewLinkedResource) []*tfprotov6.NewLinkedResource { + if in == nil { + return nil + } + + linkedResources := make([]*tfprotov6.NewLinkedResource, 0, len(in)) + + for _, inLinkedResource := range in { + if inLinkedResource == nil { + linkedResources = append(linkedResources, nil) + continue + } + + linkedResources = append(linkedResources, &tfprotov6.NewLinkedResource{ + NewState: DynamicValue(inLinkedResource.NewState), + NewIdentity: ResourceIdentityData(inLinkedResource.NewIdentity), + RequiresReplace: inLinkedResource.RequiresReplace, + }) + } + + return linkedResources +} diff --git a/internal/tfprotov5tov6/tfprotov5tov6_test.go b/internal/tfprotov5tov6/tfprotov5tov6_test.go index 6f326ea..5d9893d 100644 --- a/internal/tfprotov5tov6/tfprotov5tov6_test.go +++ b/internal/tfprotov5tov6/tfprotov5tov6_test.go @@ -19,6 +19,14 @@ import ( var ( testBytes []byte = []byte("test") + testTfprotov5ActionMetadata tfprotov5.ActionMetadata = tfprotov5.ActionMetadata{ + TypeName: "test_action", + } + + testTfprotov6ActionMetadata tfprotov6.ActionMetadata = tfprotov6.ActionMetadata{ + TypeName: "test_action", + } + testTfprotov5DataSourceMetadata tfprotov5.DataSourceMetadata = tfprotov5.DataSourceMetadata{ TypeName: "test_data_source", } @@ -945,6 +953,9 @@ func TestGetMetadataResponse(t *testing.T) { }, "all-valid-fields": { in: &tfprotov5.GetMetadataResponse{ + Actions: []tfprotov5.ActionMetadata{ + testTfprotov5ActionMetadata, + }, DataSources: []tfprotov5.DataSourceMetadata{ testTfprotov5DataSourceMetadata, }, @@ -963,6 +974,9 @@ func TestGetMetadataResponse(t *testing.T) { }, }, expected: &tfprotov6.GetMetadataResponse{ + Actions: []tfprotov6.ActionMetadata{ + testTfprotov6ActionMetadata, + }, DataSources: []tfprotov6.DataSourceMetadata{ testTfprotov6DataSourceMetadata, }, @@ -1041,6 +1055,12 @@ func TestGetProviderSchemaResponse(t *testing.T) { }, "all-valid-fields": { in: &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_action": { + Schema: testTfprotov5Schema, + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + }, DataSourceSchemas: map[string]*tfprotov5.Schema{ "test_data_source": testTfprotov5Schema, }, @@ -1061,6 +1081,12 @@ func TestGetProviderSchemaResponse(t *testing.T) { }, }, expected: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_action": { + Schema: testTfprotov6Schema, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + }, DataSourceSchemas: map[string]*tfprotov6.Schema{ "test_data_source": testTfprotov6Schema, }, @@ -3126,6 +3152,425 @@ func TestListResourceResult(t *testing.T) { } } +func TestActionSchema(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov5.ActionSchema + expected *tfprotov6.ActionSchema + }{ + "nil": { + in: nil, + expected: nil, + }, + "unlinked": { + in: &tfprotov5.ActionSchema{ + Schema: testTfprotov5Schema, + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + expected: &tfprotov6.ActionSchema{ + Schema: testTfprotov6Schema, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + }, + "lifecycle": { + in: &tfprotov5.ActionSchema{ + Schema: testTfprotov5Schema, + Type: tfprotov5.LifecycleActionSchemaType{ + Executes: tfprotov5.LifecycleExecutionOrderAfter, + LinkedResource: &tfprotov5.LinkedResourceSchema{ + TypeName: "test_resource_linked_1", + Description: "This is a linked resource.", + }, + }, + }, + expected: &tfprotov6.ActionSchema{ + Schema: testTfprotov6Schema, + Type: tfprotov6.LifecycleActionSchemaType{ + Executes: tfprotov6.LifecycleExecutionOrderAfter, + LinkedResource: &tfprotov6.LinkedResourceSchema{ + TypeName: "test_resource_linked_1", + Description: "This is a linked resource.", + }, + }, + }, + }, + "linked": { + in: &tfprotov5.ActionSchema{ + Schema: testTfprotov5Schema, + Type: tfprotov5.LinkedActionSchemaType{ + LinkedResources: []*tfprotov5.LinkedResourceSchema{ + { + TypeName: "test_resource_linked_1", + Description: "This is a linked resource.", + }, + { + TypeName: "test_resource_linked_2", + Description: "This is also a linked resource.", + }, + }, + }, + }, + expected: &tfprotov6.ActionSchema{ + Schema: testTfprotov6Schema, + Type: tfprotov6.LinkedActionSchemaType{ + LinkedResources: []*tfprotov6.LinkedResourceSchema{ + { + TypeName: "test_resource_linked_1", + Description: "This is a linked resource.", + }, + { + TypeName: "test_resource_linked_2", + Description: "This is also a linked resource.", + }, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := tfprotov5tov6.ActionSchema(testCase.in) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestPlanActionRequest(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov5.PlanActionRequest + expected *tfprotov6.PlanActionRequest + }{ + "nil": { + in: nil, + expected: nil, + }, + "no-linked-resources": { + in: &tfprotov5.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov5DynamicValue, + }, + expected: &tfprotov6.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov6DynamicValue, + }, + }, + "linked-resources": { + in: &tfprotov5.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov5DynamicValue, + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: &testTfprotov5DynamicValue, + PlannedState: &testTfprotov5DynamicValue, + Config: &testTfprotov5DynamicValue, + PriorIdentity: &testTfprotov5ResourceIdentityData, + }, + { + PriorState: &testTfprotov5DynamicValue, + PlannedState: &testTfprotov5DynamicValue, + Config: &testTfprotov5DynamicValue, + PriorIdentity: &testTfprotov5ResourceIdentityData, + }, + }, + }, + expected: &tfprotov6.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov6DynamicValue, + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: &testTfprotov6DynamicValue, + PlannedState: &testTfprotov6DynamicValue, + Config: &testTfprotov6DynamicValue, + PriorIdentity: &testTfprotov6ResourceIdentityData, + }, + { + PriorState: &testTfprotov6DynamicValue, + PlannedState: &testTfprotov6DynamicValue, + Config: &testTfprotov6DynamicValue, + PriorIdentity: &testTfprotov6ResourceIdentityData, + }, + }, + }, + }, + "client-capabilities-deferral-allowed": { + in: &tfprotov5.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov5DynamicValue, + ClientCapabilities: &tfprotov5.PlanActionClientCapabilities{ + DeferralAllowed: true, + }, + }, + expected: &tfprotov6.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov6DynamicValue, + ClientCapabilities: &tfprotov6.PlanActionClientCapabilities{ + DeferralAllowed: true, + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := tfprotov5tov6.PlanActionRequest(testCase.in) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestPlanActionResponse(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov5.PlanActionResponse + expected *tfprotov6.PlanActionResponse + }{ + "nil": { + in: nil, + expected: nil, + }, + "no-linked-resources": { + in: &tfprotov5.PlanActionResponse{ + Diagnostics: testTfprotov5Diagnostics, + }, + expected: &tfprotov6.PlanActionResponse{ + Diagnostics: testTfprotov6Diagnostics, + }, + }, + "linked-resources": { + in: &tfprotov5.PlanActionResponse{ + Diagnostics: testTfprotov5Diagnostics, + LinkedResources: []*tfprotov5.PlannedLinkedResource{ + { + PlannedState: &testTfprotov5DynamicValue, + PlannedIdentity: &testTfprotov5ResourceIdentityData, + }, + { + PlannedState: &testTfprotov5DynamicValue, + PlannedIdentity: &testTfprotov5ResourceIdentityData, + }, + }, + }, + expected: &tfprotov6.PlanActionResponse{ + Diagnostics: testTfprotov6Diagnostics, + LinkedResources: []*tfprotov6.PlannedLinkedResource{ + { + PlannedState: &testTfprotov6DynamicValue, + PlannedIdentity: &testTfprotov6ResourceIdentityData, + }, + { + PlannedState: &testTfprotov6DynamicValue, + PlannedIdentity: &testTfprotov6ResourceIdentityData, + }, + }, + }, + }, + "deferred-reason": { + in: &tfprotov5.PlanActionResponse{ + Diagnostics: testTfprotov5Diagnostics, + Deferred: &tfprotov5.Deferred{ + Reason: tfprotov5.DeferredReasonResourceConfigUnknown, + }, + }, + expected: &tfprotov6.PlanActionResponse{ + Diagnostics: testTfprotov6Diagnostics, + Deferred: &tfprotov6.Deferred{ + Reason: tfprotov6.DeferredReasonResourceConfigUnknown, + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := tfprotov5tov6.PlanActionResponse(testCase.in) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestInvokeActionRequest(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov5.InvokeActionRequest + expected *tfprotov6.InvokeActionRequest + }{ + "nil": { + in: nil, + expected: nil, + }, + "no-linked-resources": { + in: &tfprotov5.InvokeActionRequest{ + ActionType: "test_action", + Config: &testTfprotov5DynamicValue, + }, + expected: &tfprotov6.InvokeActionRequest{ + ActionType: "test_action", + Config: &testTfprotov6DynamicValue, + }, + }, + "linked-resources": { + in: &tfprotov5.InvokeActionRequest{ + ActionType: "test_action", + Config: &testTfprotov5DynamicValue, + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: &testTfprotov5DynamicValue, + PlannedState: &testTfprotov5DynamicValue, + Config: &testTfprotov5DynamicValue, + PlannedIdentity: &testTfprotov5ResourceIdentityData, + }, + { + PriorState: &testTfprotov5DynamicValue, + PlannedState: &testTfprotov5DynamicValue, + Config: &testTfprotov5DynamicValue, + PlannedIdentity: &testTfprotov5ResourceIdentityData, + }, + }, + }, + expected: &tfprotov6.InvokeActionRequest{ + ActionType: "test_action", + Config: &testTfprotov6DynamicValue, + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: &testTfprotov6DynamicValue, + PlannedState: &testTfprotov6DynamicValue, + Config: &testTfprotov6DynamicValue, + PlannedIdentity: &testTfprotov6ResourceIdentityData, + }, + { + PriorState: &testTfprotov6DynamicValue, + PlannedState: &testTfprotov6DynamicValue, + Config: &testTfprotov6DynamicValue, + PlannedIdentity: &testTfprotov6ResourceIdentityData, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := tfprotov5tov6.InvokeActionRequest(testCase.in) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestInvokeActionServerStream(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov5.InvokeActionServerStream + expected *tfprotov6.InvokeActionServerStream + }{ + "nil": { + in: nil, + expected: nil, + }, + "all-valid-fields": { + in: &tfprotov5.InvokeActionServerStream{ + Events: slices.Values([]tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: "in progress", + }, + }, + { + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{ + { + NewState: &testTfprotov5DynamicValue, + NewIdentity: &testTfprotov5ResourceIdentityData, + RequiresReplace: true, + }, + }, + Diagnostics: testTfprotov5Diagnostics, + }, + }, + }), + }, + expected: &tfprotov6.InvokeActionServerStream{ + Events: slices.Values([]tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: "in progress", + }, + }, + { + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{ + { + NewState: &testTfprotov6DynamicValue, + NewIdentity: &testTfprotov6ResourceIdentityData, + RequiresReplace: true, + }, + }, + Diagnostics: testTfprotov6Diagnostics, + }, + }, + }), + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := tfprotov5tov6.InvokeActionServerStream(testCase.in) + + if got == nil { + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + } else { + gotSlice := slices.Collect(got.Events) + + expectedSlice := slices.Collect(got.Events) + + if len(expectedSlice) != len(gotSlice) { + t.Fatalf("expected iterator and event iterator lengths do not match") + } + + if diff := cmp.Diff(gotSlice, expectedSlice); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + } + }) + } +} + func pointer[T any](value T) *T { return &value } diff --git a/internal/tfprotov6tov5/tfprotov6tov5.go b/internal/tfprotov6tov5/tfprotov6tov5.go index 4e6f28e..e53ce78 100644 --- a/internal/tfprotov6tov5/tfprotov6tov5.go +++ b/internal/tfprotov6tov5/tfprotov6tov5.go @@ -306,6 +306,7 @@ func GetMetadataResponse(in *tfprotov6.GetMetadataResponse) *tfprotov5.GetMetada } resp := &tfprotov5.GetMetadataResponse{ + Actions: make([]tfprotov5.ActionMetadata, 0, len(in.Actions)), DataSources: make([]tfprotov5.DataSourceMetadata, 0, len(in.DataSources)), Diagnostics: Diagnostics(in.Diagnostics), EphemeralResources: make([]tfprotov5.EphemeralResourceMetadata, 0, len(in.Resources)), @@ -335,6 +336,10 @@ func GetMetadataResponse(in *tfprotov6.GetMetadataResponse) *tfprotov5.GetMetada resp.Resources = append(resp.Resources, ResourceMetadata(resource)) } + for _, action := range in.Actions { + resp.Actions = append(resp.Actions, ActionMetadata(action)) + } + return resp } @@ -417,7 +422,20 @@ func GetProviderSchemaResponse(in *tfprotov6.GetProviderSchemaResponse) (*tfprot resourceSchemas[k] = v5Schema } + actionSchemas := make(map[string]*tfprotov5.ActionSchema, len(in.ActionSchemas)) + + for k, v := range in.ActionSchemas { + actionSchema, err := ActionSchema(v) + + if err != nil { + return nil, fmt.Errorf("unable to convert action %q schema: %w", k, err) + } + + actionSchemas[k] = actionSchema + } + return &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: actionSchemas, DataSourceSchemas: dataSourceSchemas, Diagnostics: Diagnostics(in.Diagnostics), EphemeralResourceSchemas: ephemeralResourceSchemas, @@ -1119,3 +1137,249 @@ func ListResourceResult(in tfprotov6.ListResourceResult) tfprotov5.ListResourceR Diagnostics: Diagnostics(in.Diagnostics), } } + +func ActionMetadata(in tfprotov6.ActionMetadata) tfprotov5.ActionMetadata { + return tfprotov5.ActionMetadata{ + TypeName: in.TypeName, + } +} + +func ActionSchema(in *tfprotov6.ActionSchema) (*tfprotov5.ActionSchema, error) { + if in == nil { + return nil, nil + } + + v5Schema, err := Schema(in.Schema) + if err != nil { + return nil, err + } + + actionSchema := &tfprotov5.ActionSchema{ + Schema: v5Schema, + } + + switch actionSchemaType := in.Type.(type) { + case tfprotov6.UnlinkedActionSchemaType: + actionSchema.Type = tfprotov5.UnlinkedActionSchemaType{} + case tfprotov6.LifecycleActionSchemaType: + actionSchema.Type = tfprotov5.LifecycleActionSchemaType{ + Executes: tfprotov5.LifecycleExecutionOrder(actionSchemaType.Executes), + LinkedResource: LinkedResourceSchema(actionSchemaType.LinkedResource), + } + case tfprotov6.LinkedActionSchemaType: + actionSchema.Type = tfprotov5.LinkedActionSchemaType{ + LinkedResources: LinkedResourceSchemas(actionSchemaType.LinkedResources), + } + default: + // It is not currently possible to create tfprotov6.ActionSchemaType + // implementations outside the terraform-plugin-go module. If this panic was reached, + // it implies that a new event type was introduced and needs to be implemented + // as a new case above. + panic(fmt.Sprintf("unimplemented tfprotov6.ActionSchemaType type: %T", in.Type)) + } + + return actionSchema, nil +} + +func LinkedResourceSchemas(in []*tfprotov6.LinkedResourceSchema) []*tfprotov5.LinkedResourceSchema { + schemas := make([]*tfprotov5.LinkedResourceSchema, 0, len(in)) + + for _, schema := range in { + schemas = append(schemas, LinkedResourceSchema(schema)) + } + + return schemas +} + +func LinkedResourceSchema(in *tfprotov6.LinkedResourceSchema) *tfprotov5.LinkedResourceSchema { + if in == nil { + return nil + } + + return &tfprotov5.LinkedResourceSchema{ + TypeName: in.TypeName, + Description: in.Description, + } +} + +func PlanActionRequest(in *tfprotov6.PlanActionRequest) *tfprotov5.PlanActionRequest { + if in == nil { + return nil + } + + return &tfprotov5.PlanActionRequest{ + ActionType: in.ActionType, + LinkedResources: ProposedLinkedResources(in.LinkedResources), + Config: DynamicValue(in.Config), + ClientCapabilities: PlanActionClientCapabilities(in.ClientCapabilities), + } +} + +func ProposedLinkedResources(in []*tfprotov6.ProposedLinkedResource) []*tfprotov5.ProposedLinkedResource { + if in == nil { + return nil + } + + linkedResources := make([]*tfprotov5.ProposedLinkedResource, 0, len(in)) + + for _, inLinkedResource := range in { + if inLinkedResource == nil { + linkedResources = append(linkedResources, nil) + continue + } + + linkedResources = append(linkedResources, &tfprotov5.ProposedLinkedResource{ + PriorState: DynamicValue(inLinkedResource.PriorState), + PlannedState: DynamicValue(inLinkedResource.PlannedState), + Config: DynamicValue(inLinkedResource.Config), + PriorIdentity: ResourceIdentityData(inLinkedResource.PriorIdentity), + }) + } + + return linkedResources +} + +func PlanActionClientCapabilities(in *tfprotov6.PlanActionClientCapabilities) *tfprotov5.PlanActionClientCapabilities { + if in == nil { + return nil + } + + resp := &tfprotov5.PlanActionClientCapabilities{ + DeferralAllowed: in.DeferralAllowed, + } + + return resp +} + +func PlanActionResponse(in *tfprotov6.PlanActionResponse) *tfprotov5.PlanActionResponse { + if in == nil { + return nil + } + + return &tfprotov5.PlanActionResponse{ + LinkedResources: PlannedLinkedResources(in.LinkedResources), + Diagnostics: Diagnostics(in.Diagnostics), + Deferred: Deferred(in.Deferred), + } +} + +func PlannedLinkedResources(in []*tfprotov6.PlannedLinkedResource) []*tfprotov5.PlannedLinkedResource { + if in == nil { + return nil + } + + linkedResources := make([]*tfprotov5.PlannedLinkedResource, 0, len(in)) + + for _, inLinkedResource := range in { + if inLinkedResource == nil { + linkedResources = append(linkedResources, nil) + continue + } + + linkedResources = append(linkedResources, &tfprotov5.PlannedLinkedResource{ + PlannedState: DynamicValue(inLinkedResource.PlannedState), + PlannedIdentity: ResourceIdentityData(inLinkedResource.PlannedIdentity), + }) + } + + return linkedResources +} + +func InvokeActionRequest(in *tfprotov6.InvokeActionRequest) *tfprotov5.InvokeActionRequest { + if in == nil { + return nil + } + + return &tfprotov5.InvokeActionRequest{ + ActionType: in.ActionType, + LinkedResources: InvokeLinkedResources(in.LinkedResources), + Config: DynamicValue(in.Config), + } +} + +func InvokeLinkedResources(in []*tfprotov6.InvokeLinkedResource) []*tfprotov5.InvokeLinkedResource { + if in == nil { + return nil + } + + linkedResources := make([]*tfprotov5.InvokeLinkedResource, 0, len(in)) + + for _, inLinkedResource := range in { + if inLinkedResource == nil { + linkedResources = append(linkedResources, nil) + continue + } + + linkedResources = append(linkedResources, &tfprotov5.InvokeLinkedResource{ + PriorState: DynamicValue(inLinkedResource.PriorState), + PlannedState: DynamicValue(inLinkedResource.PlannedState), + Config: DynamicValue(inLinkedResource.Config), + PlannedIdentity: ResourceIdentityData(inLinkedResource.PlannedIdentity), + }) + } + + return linkedResources +} + +func InvokeActionServerStream(in *tfprotov6.InvokeActionServerStream) *tfprotov5.InvokeActionServerStream { + if in == nil { + return nil + } + + return &tfprotov5.InvokeActionServerStream{ + Events: func(yield func(tfprotov5.InvokeActionEvent) bool) { + for res := range in.Events { + if !yield(InvokeActionEvent(res)) { + break + } + } + }, + } +} + +func InvokeActionEvent(in tfprotov6.InvokeActionEvent) tfprotov5.InvokeActionEvent { + switch event := (in.Type).(type) { + case tfprotov6.ProgressInvokeActionEventType: + return tfprotov5.InvokeActionEvent{ + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: event.Message, + }, + } + case tfprotov6.CompletedInvokeActionEventType: + return tfprotov5.InvokeActionEvent{ + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: NewLinkedResources(event.LinkedResources), + Diagnostics: Diagnostics(event.Diagnostics), + }, + } + } + + // It is not currently possible to create tfprotov6.InvokeActionEventType + // implementations outside the terraform-plugin-go module. If this panic was reached, + // it implies that a new event type was introduced and needs to be implemented + // as a new case above. + panic(fmt.Sprintf("unimplemented tfprotov6.InvokeActionEventType type: %T", in.Type)) +} + +func NewLinkedResources(in []*tfprotov6.NewLinkedResource) []*tfprotov5.NewLinkedResource { + if in == nil { + return nil + } + + linkedResources := make([]*tfprotov5.NewLinkedResource, 0, len(in)) + + for _, inLinkedResource := range in { + if inLinkedResource == nil { + linkedResources = append(linkedResources, nil) + continue + } + + linkedResources = append(linkedResources, &tfprotov5.NewLinkedResource{ + NewState: DynamicValue(inLinkedResource.NewState), + NewIdentity: ResourceIdentityData(inLinkedResource.NewIdentity), + RequiresReplace: inLinkedResource.RequiresReplace, + }) + } + + return linkedResources +} diff --git a/internal/tfprotov6tov5/tfprotov6tov5_test.go b/internal/tfprotov6tov5/tfprotov6tov5_test.go index d323bc6..c2efdd5 100644 --- a/internal/tfprotov6tov5/tfprotov6tov5_test.go +++ b/internal/tfprotov6tov5/tfprotov6tov5_test.go @@ -21,6 +21,14 @@ import ( var ( testBytes []byte = []byte("test") + testTfprotov5ActionMetadata tfprotov5.ActionMetadata = tfprotov5.ActionMetadata{ + TypeName: "test_action", + } + + testTfprotov6ActionMetadata tfprotov6.ActionMetadata = tfprotov6.ActionMetadata{ + TypeName: "test_action", + } + testTfprotov5DataSourceMetadata tfprotov5.DataSourceMetadata = tfprotov5.DataSourceMetadata{ TypeName: "test_data_source", } @@ -947,6 +955,9 @@ func TestGetMetadataResponse(t *testing.T) { }, "all-valid-fields": { in: &tfprotov6.GetMetadataResponse{ + Actions: []tfprotov6.ActionMetadata{ + testTfprotov6ActionMetadata, + }, DataSources: []tfprotov6.DataSourceMetadata{ testTfprotov6DataSourceMetadata, }, @@ -965,6 +976,9 @@ func TestGetMetadataResponse(t *testing.T) { }, }, expected: &tfprotov5.GetMetadataResponse{ + Actions: []tfprotov5.ActionMetadata{ + testTfprotov5ActionMetadata, + }, DataSources: []tfprotov5.DataSourceMetadata{ testTfprotov5DataSourceMetadata, }, @@ -1044,6 +1058,12 @@ func TestGetProviderSchemaResponse(t *testing.T) { }, "all-valid-fields": { in: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_action": { + Schema: testTfprotov6Schema, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + }, DataSourceSchemas: map[string]*tfprotov6.Schema{ "test_data_source": testTfprotov6Schema, }, @@ -1064,6 +1084,12 @@ func TestGetProviderSchemaResponse(t *testing.T) { }, }, expected: &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_action": { + Schema: testTfprotov5Schema, + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + }, DataSourceSchemas: map[string]*tfprotov5.Schema{ "test_data_source": testTfprotov5Schema, }, @@ -1210,6 +1236,35 @@ func TestGetProviderSchemaResponse(t *testing.T) { expected: nil, expectedError: fmt.Errorf("unable to convert resource \"test_resource\" schema: unable to convert attribute \"test_attribute\" schema: %w", tfprotov6tov5.ErrSchemaAttributeNestedTypeNotImplemented), }, + "action-nested-attribute-error": { + in: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_action": { + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_attribute", + NestedType: &tfprotov6.SchemaObject{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_nested_attribute", + Required: true, + }, + }, + }, + Required: true, + }, + }, + }, + }, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + }, + }, + expected: nil, + expectedError: fmt.Errorf("unable to convert action \"test_action\" schema: unable to convert attribute \"test_attribute\" schema: %w", tfprotov6tov5.ErrSchemaAttributeNestedTypeNotImplemented), + }, } for name, testCase := range testCases { @@ -3429,6 +3484,463 @@ func TestListResourceResult(t *testing.T) { } } +func TestActionSchema(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov6.ActionSchema + expected *tfprotov5.ActionSchema + expectedError error + }{ + "nil": { + in: nil, + expected: nil, + }, + "unlinked": { + in: &tfprotov6.ActionSchema{ + Schema: testTfprotov6Schema, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + expected: &tfprotov5.ActionSchema{ + Schema: testTfprotov5Schema, + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + }, + "lifecycle": { + in: &tfprotov6.ActionSchema{ + Schema: testTfprotov6Schema, + Type: tfprotov6.LifecycleActionSchemaType{ + Executes: tfprotov6.LifecycleExecutionOrderAfter, + LinkedResource: &tfprotov6.LinkedResourceSchema{ + TypeName: "test_resource_linked_1", + Description: "This is a linked resource.", + }, + }, + }, + expected: &tfprotov5.ActionSchema{ + Schema: testTfprotov5Schema, + Type: tfprotov5.LifecycleActionSchemaType{ + Executes: tfprotov5.LifecycleExecutionOrderAfter, + LinkedResource: &tfprotov5.LinkedResourceSchema{ + TypeName: "test_resource_linked_1", + Description: "This is a linked resource.", + }, + }, + }, + }, + "linked": { + in: &tfprotov6.ActionSchema{ + Schema: testTfprotov6Schema, + Type: tfprotov6.LinkedActionSchemaType{ + LinkedResources: []*tfprotov6.LinkedResourceSchema{ + { + TypeName: "test_resource_linked_1", + Description: "This is a linked resource.", + }, + { + TypeName: "test_resource_linked_2", + Description: "This is also a linked resource.", + }, + }, + }, + }, + expected: &tfprotov5.ActionSchema{ + Schema: testTfprotov5Schema, + Type: tfprotov5.LinkedActionSchemaType{ + LinkedResources: []*tfprotov5.LinkedResourceSchema{ + { + TypeName: "test_resource_linked_1", + Description: "This is a linked resource.", + }, + { + TypeName: "test_resource_linked_2", + Description: "This is also a linked resource.", + }, + }, + }, + }, + }, + "nested-attribute-error": { + in: &tfprotov6.ActionSchema{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_attribute", + NestedType: &tfprotov6.SchemaObject{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_nested_attribute", + Required: true, + }, + }, + }, + Required: true, + }, + }, + }, + }, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + expected: nil, + expectedError: fmt.Errorf("unable to convert attribute \"test_attribute\" schema: %w", tfprotov6tov5.ErrSchemaAttributeNestedTypeNotImplemented), + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := tfprotov6tov5.ActionSchema(testCase.in) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("wanted no error, got unexpected error: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("expected error %q, got: %s", testCase.expectedError, err) + } + } else if testCase.expectedError != nil { + t.Fatalf("got no error, expected error: %s", testCase.expectedError) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestPlanActionRequest(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov6.PlanActionRequest + expected *tfprotov5.PlanActionRequest + }{ + "nil": { + in: nil, + expected: nil, + }, + "no-linked-resources": { + in: &tfprotov6.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov6DynamicValue, + }, + expected: &tfprotov5.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov5DynamicValue, + }, + }, + "linked-resources": { + in: &tfprotov6.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov6DynamicValue, + LinkedResources: []*tfprotov6.ProposedLinkedResource{ + { + PriorState: &testTfprotov6DynamicValue, + PlannedState: &testTfprotov6DynamicValue, + Config: &testTfprotov6DynamicValue, + PriorIdentity: &testTfprotov6ResourceIdentityData, + }, + { + PriorState: &testTfprotov6DynamicValue, + PlannedState: &testTfprotov6DynamicValue, + Config: &testTfprotov6DynamicValue, + PriorIdentity: &testTfprotov6ResourceIdentityData, + }, + }, + }, + expected: &tfprotov5.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov5DynamicValue, + LinkedResources: []*tfprotov5.ProposedLinkedResource{ + { + PriorState: &testTfprotov5DynamicValue, + PlannedState: &testTfprotov5DynamicValue, + Config: &testTfprotov5DynamicValue, + PriorIdentity: &testTfprotov5ResourceIdentityData, + }, + { + PriorState: &testTfprotov5DynamicValue, + PlannedState: &testTfprotov5DynamicValue, + Config: &testTfprotov5DynamicValue, + PriorIdentity: &testTfprotov5ResourceIdentityData, + }, + }, + }, + }, + "client-capabilities-deferral-allowed": { + in: &tfprotov6.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov6DynamicValue, + ClientCapabilities: &tfprotov6.PlanActionClientCapabilities{ + DeferralAllowed: true, + }, + }, + expected: &tfprotov5.PlanActionRequest{ + ActionType: "test_action", + Config: &testTfprotov5DynamicValue, + ClientCapabilities: &tfprotov5.PlanActionClientCapabilities{ + DeferralAllowed: true, + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := tfprotov6tov5.PlanActionRequest(testCase.in) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestPlanActionResponse(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov6.PlanActionResponse + expected *tfprotov5.PlanActionResponse + }{ + "nil": { + in: nil, + expected: nil, + }, + "no-linked-resources": { + in: &tfprotov6.PlanActionResponse{ + Diagnostics: testTfprotov6Diagnostics, + }, + expected: &tfprotov5.PlanActionResponse{ + Diagnostics: testTfprotov5Diagnostics, + }, + }, + "linked-resources": { + in: &tfprotov6.PlanActionResponse{ + Diagnostics: testTfprotov6Diagnostics, + LinkedResources: []*tfprotov6.PlannedLinkedResource{ + { + PlannedState: &testTfprotov6DynamicValue, + PlannedIdentity: &testTfprotov6ResourceIdentityData, + }, + { + PlannedState: &testTfprotov6DynamicValue, + PlannedIdentity: &testTfprotov6ResourceIdentityData, + }, + }, + }, + expected: &tfprotov5.PlanActionResponse{ + Diagnostics: testTfprotov5Diagnostics, + LinkedResources: []*tfprotov5.PlannedLinkedResource{ + { + PlannedState: &testTfprotov5DynamicValue, + PlannedIdentity: &testTfprotov5ResourceIdentityData, + }, + { + PlannedState: &testTfprotov5DynamicValue, + PlannedIdentity: &testTfprotov5ResourceIdentityData, + }, + }, + }, + }, + "deferred-reason": { + in: &tfprotov6.PlanActionResponse{ + Diagnostics: testTfprotov6Diagnostics, + Deferred: &tfprotov6.Deferred{ + Reason: tfprotov6.DeferredReasonResourceConfigUnknown, + }, + }, + expected: &tfprotov5.PlanActionResponse{ + Diagnostics: testTfprotov5Diagnostics, + Deferred: &tfprotov5.Deferred{ + Reason: tfprotov5.DeferredReasonResourceConfigUnknown, + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := tfprotov6tov5.PlanActionResponse(testCase.in) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestInvokeActionRequest(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov6.InvokeActionRequest + expected *tfprotov5.InvokeActionRequest + }{ + "nil": { + in: nil, + expected: nil, + }, + "no-linked-resources": { + in: &tfprotov6.InvokeActionRequest{ + ActionType: "test_action", + Config: &testTfprotov6DynamicValue, + }, + expected: &tfprotov5.InvokeActionRequest{ + ActionType: "test_action", + Config: &testTfprotov5DynamicValue, + }, + }, + "linked-resources": { + in: &tfprotov6.InvokeActionRequest{ + ActionType: "test_action", + Config: &testTfprotov6DynamicValue, + LinkedResources: []*tfprotov6.InvokeLinkedResource{ + { + PriorState: &testTfprotov6DynamicValue, + PlannedState: &testTfprotov6DynamicValue, + Config: &testTfprotov6DynamicValue, + PlannedIdentity: &testTfprotov6ResourceIdentityData, + }, + { + PriorState: &testTfprotov6DynamicValue, + PlannedState: &testTfprotov6DynamicValue, + Config: &testTfprotov6DynamicValue, + PlannedIdentity: &testTfprotov6ResourceIdentityData, + }, + }, + }, + expected: &tfprotov5.InvokeActionRequest{ + ActionType: "test_action", + Config: &testTfprotov5DynamicValue, + LinkedResources: []*tfprotov5.InvokeLinkedResource{ + { + PriorState: &testTfprotov5DynamicValue, + PlannedState: &testTfprotov5DynamicValue, + Config: &testTfprotov5DynamicValue, + PlannedIdentity: &testTfprotov5ResourceIdentityData, + }, + { + PriorState: &testTfprotov5DynamicValue, + PlannedState: &testTfprotov5DynamicValue, + Config: &testTfprotov5DynamicValue, + PlannedIdentity: &testTfprotov5ResourceIdentityData, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := tfprotov6tov5.InvokeActionRequest(testCase.in) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestInvokeActionServerStream(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + in *tfprotov6.InvokeActionServerStream + expected *tfprotov5.InvokeActionServerStream + }{ + "nil": { + in: nil, + expected: nil, + }, + "all-valid-fields": { + in: &tfprotov6.InvokeActionServerStream{ + Events: slices.Values([]tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.ProgressInvokeActionEventType{ + Message: "in progress", + }, + }, + { + Type: tfprotov6.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov6.NewLinkedResource{ + { + NewState: &testTfprotov6DynamicValue, + NewIdentity: &testTfprotov6ResourceIdentityData, + RequiresReplace: true, + }, + }, + Diagnostics: testTfprotov6Diagnostics, + }, + }, + }), + }, + expected: &tfprotov5.InvokeActionServerStream{ + Events: slices.Values([]tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.ProgressInvokeActionEventType{ + Message: "in progress", + }, + }, + { + Type: tfprotov5.CompletedInvokeActionEventType{ + LinkedResources: []*tfprotov5.NewLinkedResource{ + { + NewState: &testTfprotov5DynamicValue, + NewIdentity: &testTfprotov5ResourceIdentityData, + RequiresReplace: true, + }, + }, + Diagnostics: testTfprotov5Diagnostics, + }, + }, + }), + }, + }, + } + + for name, testCase := range testCases { + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := tfprotov6tov5.InvokeActionServerStream(testCase.in) + + if got == nil { + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + } else { + gotSlice := slices.Collect(got.Events) + + expectedSlice := slices.Collect(got.Events) + + if len(expectedSlice) != len(gotSlice) { + t.Fatalf("expected iterator and event iterator lengths do not match") + } + + if diff := cmp.Diff(gotSlice, expectedSlice); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + } + }) + } +} + func pointer[T any](value T) *T { return &value } diff --git a/tf5muxserver/diagnostics.go b/tf5muxserver/diagnostics.go index aa11362..406120f 100644 --- a/tf5muxserver/diagnostics.go +++ b/tf5muxserver/diagnostics.go @@ -5,6 +5,27 @@ package tf5muxserver import "github.com/hashicorp/terraform-plugin-go/tfprotov5" +func actionDuplicateError(actionType string) *tfprotov5.Diagnostic { + return &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Invalid Provider Server Combination", + Detail: "The combined provider has multiple implementations of the same action type across underlying providers. " + + "Actions must be implemented by only one underlying provider. " + + "This is always an issue in the provider implementation and should be reported to the provider developers.\n\n" + + "Duplicate action: " + actionType, + } +} + +func actionMissingError(actionType string) *tfprotov5.Diagnostic { + return &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Action Not Implemented", + Detail: "The combined provider does not implement the requested action. " + + "This is always an issue in the provider implementation and should be reported to the provider developers.\n\n" + + "Missing action: " + actionType, + } +} + func dataSourceDuplicateError(typeName string) *tfprotov5.Diagnostic { return &tfprotov5.Diagnostic{ Severity: tfprotov5.DiagnosticSeverityError, diff --git a/tf5muxserver/mux_server.go b/tf5muxserver/mux_server.go index ab0393b..0122000 100644 --- a/tf5muxserver/mux_server.go +++ b/tf5muxserver/mux_server.go @@ -20,6 +20,9 @@ var _ tfprotov5.ProviderServer = &muxServer{} // gRPC servers, routing requests to them as if they were a single server. It // should always be instantiated by calling NewMuxServer(). type muxServer struct { + // Routing for actions + actions map[string]tfprotov5.ProviderServer + // Routing for data source types dataSources map[string]tfprotov5.ProviderServer @@ -62,6 +65,41 @@ func (s *muxServer) ProviderServer() tfprotov5.ProviderServer { return s } +func (s *muxServer) getActionServer(ctx context.Context, actionType string) (tfprotov5.ProviderServer, []*tfprotov5.Diagnostic, error) { + s.serverDiscoveryMutex.RLock() + server, ok := s.actions[actionType] + discoveryComplete := s.serverDiscoveryComplete + s.serverDiscoveryMutex.RUnlock() + + if discoveryComplete { + if ok { + return server, s.serverDiscoveryDiagnostics, nil + } + + return nil, []*tfprotov5.Diagnostic{ + actionMissingError(actionType), + }, nil + } + + err := s.serverDiscovery(ctx) + + if err != nil || diagnosticsHasError(s.serverDiscoveryDiagnostics) { + return nil, s.serverDiscoveryDiagnostics, err + } + + s.serverDiscoveryMutex.RLock() + server, ok = s.actions[actionType] + s.serverDiscoveryMutex.RUnlock() + + if !ok { + return nil, []*tfprotov5.Diagnostic{ + actionMissingError(actionType), + }, nil + } + + return server, s.serverDiscoveryDiagnostics, nil +} + func (s *muxServer) getDataSourceServer(ctx context.Context, typeName string) (tfprotov5.ProviderServer, []*tfprotov5.Diagnostic, error) { s.serverDiscoveryMutex.RLock() server, ok := s.dataSources[typeName] @@ -240,7 +278,7 @@ func (s *muxServer) getResourceServer(ctx context.Context, typeName string) (tfp // serverDiscovery will populate the mux server "routing" for functions and // resource types by calling all underlying server GetMetadata RPC and falling // back to GetProviderSchema RPC. It is intended to only be called through -// getDataSourceServer, getEphemeralResourceServer, getListResourceServer, +// getActionServer, getDataSourceServer, getEphemeralResourceServer, getListResourceServer, // getFunctionServer, and getResourceServer. // // The error return represents gRPC errors, which except for the GetMetadata @@ -269,6 +307,16 @@ func (s *muxServer) serverDiscovery(ctx context.Context) error { // Collect all underlying server diagnostics, but skip early return. s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, metadataResp.Diagnostics...) + for _, serverAction := range metadataResp.Actions { + if _, ok := s.actions[serverAction.TypeName]; ok { + s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, actionDuplicateError(serverAction.TypeName)) + + continue + } + + s.actions[serverAction.TypeName] = server + } + for _, serverDataSource := range metadataResp.DataSources { if _, ok := s.dataSources[serverDataSource.TypeName]; ok { s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, dataSourceDuplicateError(serverDataSource.TypeName)) @@ -341,6 +389,16 @@ func (s *muxServer) serverDiscovery(ctx context.Context) error { // Collect all underlying server diagnostics, but skip early return. s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, providerSchemaResp.Diagnostics...) + for actionType := range providerSchemaResp.ActionSchemas { + if _, ok := s.actions[actionType]; ok { + s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, actionDuplicateError(actionType)) + + continue + } + + s.actions[actionType] = server + } + for typeName := range providerSchemaResp.DataSourceSchemas { if _, ok := s.dataSources[typeName]; ok { s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, dataSourceDuplicateError(typeName)) @@ -404,6 +462,7 @@ func (s *muxServer) serverDiscovery(ctx context.Context) error { // // - All provider schemas exactly match // - All provider meta schemas exactly match +// - Only one provider implements each action // - Only one provider implements each managed resource // - Only one provider implements each data source // - Only one provider implements each function @@ -412,6 +471,7 @@ func (s *muxServer) serverDiscovery(ctx context.Context) error { // - Only one provider implements each resource identity func NewMuxServer(_ context.Context, servers ...func() tfprotov5.ProviderServer) (*muxServer, error) { result := muxServer{ + actions: make(map[string]tfprotov5.ProviderServer), dataSources: make(map[string]tfprotov5.ProviderServer), ephemeralResources: make(map[string]tfprotov5.ProviderServer), listResources: make(map[string]tfprotov5.ProviderServer), diff --git a/tf5muxserver/mux_server_GetMetadata.go b/tf5muxserver/mux_server_GetMetadata.go index ef1b054..88c666e 100644 --- a/tf5muxserver/mux_server_GetMetadata.go +++ b/tf5muxserver/mux_server_GetMetadata.go @@ -14,7 +14,7 @@ import ( // GetMetadata merges the metadata returned by the // tfprotov5.ProviderServers associated with muxServer into a single response. -// Resources, data sources, ephemeral resources, list resources, and functions must be returned +// Resources, data sources, ephemeral resources, list resources, actions, and functions must be returned // from only one server or an error diagnostic is returned. func (s *muxServer) GetMetadata(ctx context.Context, req *tfprotov5.GetMetadataRequest) (*tfprotov5.GetMetadataResponse, error) { rpc := "GetMetadata" @@ -25,6 +25,7 @@ func (s *muxServer) GetMetadata(ctx context.Context, req *tfprotov5.GetMetadataR defer s.serverDiscoveryMutex.Unlock() resp := &tfprotov5.GetMetadataResponse{ + Actions: make([]tfprotov5.ActionMetadata, 0), DataSources: make([]tfprotov5.DataSourceMetadata, 0), EphemeralResources: make([]tfprotov5.EphemeralResourceMetadata, 0), ListResources: make([]tfprotov5.ListResourceMetadata, 0), @@ -45,6 +46,17 @@ func (s *muxServer) GetMetadata(ctx context.Context, req *tfprotov5.GetMetadataR resp.Diagnostics = append(resp.Diagnostics, serverResp.Diagnostics...) + for _, action := range serverResp.Actions { + if actionMetadataContainsTypeName(resp.Actions, action.TypeName) { + resp.Diagnostics = append(resp.Diagnostics, actionDuplicateError(action.TypeName)) + + continue + } + + s.actions[action.TypeName] = server + resp.Actions = append(resp.Actions, action) + } + for _, datasource := range serverResp.DataSources { if datasourceMetadataContainsTypeName(resp.DataSources, datasource.TypeName) { resp.Diagnostics = append(resp.Diagnostics, dataSourceDuplicateError(datasource.TypeName)) @@ -105,6 +117,16 @@ func (s *muxServer) GetMetadata(ctx context.Context, req *tfprotov5.GetMetadataR return resp, nil } +func actionMetadataContainsTypeName(metadatas []tfprotov5.ActionMetadata, typeName string) bool { + for _, metadata := range metadatas { + if typeName == metadata.TypeName { + return true + } + } + + return false +} + func datasourceMetadataContainsTypeName(metadatas []tfprotov5.DataSourceMetadata, typeName string) bool { for _, metadata := range metadatas { if typeName == metadata.TypeName { diff --git a/tf5muxserver/mux_server_GetMetadata_test.go b/tf5muxserver/mux_server_GetMetadata_test.go index ba4dfd3..1463d57 100644 --- a/tf5muxserver/mux_server_GetMetadata_test.go +++ b/tf5muxserver/mux_server_GetMetadata_test.go @@ -19,6 +19,7 @@ func TestMuxServerGetMetadata(t *testing.T) { testCases := map[string]struct { servers []func() tfprotov5.ProviderServer + expectedActions []tfprotov5.ActionMetadata expectedDataSources []tfprotov5.DataSourceMetadata expectedDiagnostics []*tfprotov5.Diagnostic expectedEphemeralResources []tfprotov5.EphemeralResourceMetadata @@ -31,6 +32,14 @@ func TestMuxServerGetMetadata(t *testing.T) { servers: []func() tfprotov5.ProviderServer{ (&tf5testserver.TestServer{ GetMetadataResponse: &tfprotov5.GetMetadataResponse{ + Actions: []tfprotov5.ActionMetadata{ + { + TypeName: "test_foo", + }, + { + TypeName: "test_bar", + }, + }, Resources: []tfprotov5.ResourceMetadata{ { TypeName: "test_foo", @@ -69,6 +78,11 @@ func TestMuxServerGetMetadata(t *testing.T) { }).ProviderServer, (&tf5testserver.TestServer{ GetMetadataResponse: &tfprotov5.GetMetadataResponse{ + Actions: []tfprotov5.ActionMetadata{ + { + TypeName: "test_quux", + }, + }, Resources: []tfprotov5.ResourceMetadata{ { TypeName: "test_quux", @@ -103,6 +117,17 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{ + { + TypeName: "test_foo", + }, + { + TypeName: "test_bar", + }, + { + TypeName: "test_quux", + }, + }, expectedResources: []tfprotov5.ResourceMetadata{ { TypeName: "test_foo", @@ -164,6 +189,53 @@ func TestMuxServerGetMetadata(t *testing.T) { PlanDestroy: true, }, }, + "duplicate-action": { + servers: []func() tfprotov5.ProviderServer{ + (&tf5testserver.TestServer{ + GetMetadataResponse: &tfprotov5.GetMetadataResponse{ + Actions: []tfprotov5.ActionMetadata{ + { + TypeName: "test_foo", + }, + }, + }, + }).ProviderServer, + (&tf5testserver.TestServer{ + GetMetadataResponse: &tfprotov5.GetMetadataResponse{ + Actions: []tfprotov5.ActionMetadata{ + { + TypeName: "test_foo", + }, + }, + }, + }).ProviderServer, + }, + expectedActions: []tfprotov5.ActionMetadata{ + { + TypeName: "test_foo", + }, + }, + expectedDataSources: []tfprotov5.DataSourceMetadata{}, + expectedDiagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Invalid Provider Server Combination", + Detail: "The combined provider has multiple implementations of the same action type across underlying providers. " + + "Actions must be implemented by only one underlying provider. " + + "This is always an issue in the provider implementation and should be reported to the provider developers.\n\n" + + "Duplicate action: test_foo", + }, + }, + expectedEphemeralResources: []tfprotov5.EphemeralResourceMetadata{}, + expectedListResources: []tfprotov5.ListResourceMetadata{}, + expectedFunctions: []tfprotov5.FunctionMetadata{}, + expectedResources: []tfprotov5.ResourceMetadata{}, + expectedServerCapabilities: &tfprotov5.ServerCapabilities{ + GetProviderSchemaOptional: true, + MoveResourceState: true, + PlanDestroy: true, + }, + }, "duplicate-data-source-type": { servers: []func() tfprotov5.ProviderServer{ (&tf5testserver.TestServer{ @@ -185,6 +257,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{}, expectedDataSources: []tfprotov5.DataSourceMetadata{ { TypeName: "test_foo", @@ -231,6 +304,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{}, expectedDataSources: []tfprotov5.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -277,6 +351,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{}, expectedDataSources: []tfprotov5.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -323,6 +398,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{}, expectedDataSources: []tfprotov5.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -369,6 +445,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{}, expectedDataSources: []tfprotov5.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -420,6 +497,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{}, expectedDataSources: []tfprotov5.DataSourceMetadata{}, expectedEphemeralResources: []tfprotov5.EphemeralResourceMetadata{}, expectedListResources: []tfprotov5.ListResourceMetadata{}, @@ -454,6 +532,7 @@ func TestMuxServerGetMetadata(t *testing.T) { (&tf5testserver.TestServer{}).ProviderServer, (&tf5testserver.TestServer{}).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{}, expectedDataSources: []tfprotov5.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -498,6 +577,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{}, expectedDataSources: []tfprotov5.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -537,6 +617,7 @@ func TestMuxServerGetMetadata(t *testing.T) { (&tf5testserver.TestServer{}).ProviderServer, (&tf5testserver.TestServer{}).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{}, expectedDataSources: []tfprotov5.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -581,6 +662,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{}, expectedDataSources: []tfprotov5.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -630,6 +712,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov5.ActionMetadata{}, expectedDataSources: []tfprotov5.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -672,6 +755,10 @@ func TestMuxServerGetMetadata(t *testing.T) { t.Fatalf("unexpected error: %s", err) } + if diff := cmp.Diff(resp.Actions, testCase.expectedActions); diff != "" { + t.Errorf("actions didn't match expectations: %s", diff) + } + if diff := cmp.Diff(resp.DataSources, testCase.expectedDataSources); diff != "" { t.Errorf("data sources didn't match expectations: %s", diff) } diff --git a/tf5muxserver/mux_server_GetProviderSchema.go b/tf5muxserver/mux_server_GetProviderSchema.go index df97f80..33b7ed4 100644 --- a/tf5muxserver/mux_server_GetProviderSchema.go +++ b/tf5muxserver/mux_server_GetProviderSchema.go @@ -14,7 +14,7 @@ import ( // GetProviderSchema merges the schemas returned by the // tfprotov5.ProviderServers associated with muxServer into a single schema. -// Resources, data sources, ephemeral resources, list resources, and functions must be returned +// Resources, data sources, ephemeral resources, list resources, actions, and functions must be returned // from only one server. Provider and ProviderMeta schemas must be identical between all servers. func (s *muxServer) GetProviderSchema(ctx context.Context, req *tfprotov5.GetProviderSchemaRequest) (*tfprotov5.GetProviderSchemaResponse, error) { rpc := "GetProviderSchema" @@ -25,6 +25,7 @@ func (s *muxServer) GetProviderSchema(ctx context.Context, req *tfprotov5.GetPro defer s.serverDiscoveryMutex.Unlock() resp := &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: make(map[string]*tfprotov5.ActionSchema), DataSourceSchemas: make(map[string]*tfprotov5.Schema), EphemeralResourceSchemas: make(map[string]*tfprotov5.Schema), ListResourceSchemas: make(map[string]*tfprotov5.Schema), @@ -75,6 +76,17 @@ func (s *muxServer) GetProviderSchema(ctx context.Context, req *tfprotov5.GetPro } } + for actionType, schema := range serverResp.ActionSchemas { + if _, ok := resp.ActionSchemas[actionType]; ok { + resp.Diagnostics = append(resp.Diagnostics, actionDuplicateError(actionType)) + + continue + } + + s.actions[actionType] = server + resp.ActionSchemas[actionType] = schema + } + for resourceType, schema := range serverResp.ResourceSchemas { if _, ok := resp.ResourceSchemas[resourceType]; ok { resp.Diagnostics = append(resp.Diagnostics, resourceDuplicateError(resourceType)) diff --git a/tf5muxserver/mux_server_GetProviderSchema_test.go b/tf5muxserver/mux_server_GetProviderSchema_test.go index 5faea90..c57e40a 100644 --- a/tf5muxserver/mux_server_GetProviderSchema_test.go +++ b/tf5muxserver/mux_server_GetProviderSchema_test.go @@ -20,6 +20,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { testCases := map[string]struct { servers []func() tfprotov5.ProviderServer + expectedActionSchemas map[string]*tfprotov5.ActionSchema expectedDataSourceSchemas map[string]*tfprotov5.Schema expectedDiagnostics []*tfprotov5.Diagnostic expectedEphemeralResourcesSchemas map[string]*tfprotov5.Schema @@ -130,6 +131,26 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, }, + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_foo": { + Schema: &tfprotov5.Schema{ + Version: 1, + Block: &tfprotov5.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "current_time", + Type: tftypes.String, + Computed: true, + Description: "the current time in RFC 3339 format", + DescriptionKind: tfprotov5.StringKindPlain, + }, + }, + }, + }, + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + }, DataSourceSchemas: map[string]*tfprotov5.Schema{ "test_foo": { Version: 1, @@ -315,6 +336,44 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, }, + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_bar": { + Schema: &tfprotov5.Schema{ + Version: 1, + Block: &tfprotov5.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "a", + Type: tftypes.Number, + Computed: true, + Description: "some field that's set by the provider", + DescriptionKind: tfprotov5.StringKindMarkdown, + }, + }, + }, + }, + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + "test_quux": { + Schema: &tfprotov5.Schema{ + Version: 1, + Block: &tfprotov5.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "abc", + Type: tftypes.Number, + Computed: true, + Description: "some other field that's set by the provider", + DescriptionKind: tfprotov5.StringKindMarkdown, + }, + }, + }, + }, + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + }, DataSourceSchemas: map[string]*tfprotov5.Schema{ "test_bar": { Version: 1, @@ -512,6 +571,62 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_foo": { + Schema: &tfprotov5.Schema{ + Version: 1, + Block: &tfprotov5.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "current_time", + Type: tftypes.String, + Computed: true, + Description: "the current time in RFC 3339 format", + DescriptionKind: tfprotov5.StringKindPlain, + }, + }, + }, + }, + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + "test_bar": { + Schema: &tfprotov5.Schema{ + Version: 1, + Block: &tfprotov5.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "a", + Type: tftypes.Number, + Computed: true, + Description: "some field that's set by the provider", + DescriptionKind: tfprotov5.StringKindMarkdown, + }, + }, + }, + }, + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + "test_quux": { + Schema: &tfprotov5.Schema{ + Version: 1, + Block: &tfprotov5.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "abc", + Type: tftypes.Number, + Computed: true, + Description: "some other field that's set by the provider", + DescriptionKind: tfprotov5.StringKindMarkdown, + }, + }, + }, + }, + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + }, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{ "test_foo": { Version: 1, @@ -690,6 +805,47 @@ func TestMuxServerGetProviderSchema(t *testing.T) { PlanDestroy: true, }, }, + "duplicate-action": { + servers: []func() tfprotov5.ProviderServer{ + (&tf5testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_foo": {}, + }, + }, + }).ProviderServer, + (&tf5testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_foo": {}, + }, + }, + }).ProviderServer, + }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_foo": {}, + }, + expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, + expectedDiagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Invalid Provider Server Combination", + Detail: "The combined provider has multiple implementations of the same action type across underlying providers. " + + "Actions must be implemented by only one underlying provider. " + + "This is always an issue in the provider implementation and should be reported to the provider developers.\n\n" + + "Duplicate action: test_foo", + }, + }, + expectedEphemeralResourcesSchemas: map[string]*tfprotov5.Schema{}, + expectedListResourcesSchemas: map[string]*tfprotov5.Schema{}, + expectedFunctions: map[string]*tfprotov5.Function{}, + expectedResourceSchemas: map[string]*tfprotov5.Schema{}, + expectedServerCapabilities: &tfprotov5.ServerCapabilities{ + GetProviderSchemaOptional: true, + MoveResourceState: true, + PlanDestroy: true, + }, + }, "duplicate-data-source-type": { servers: []func() tfprotov5.ProviderServer{ (&tf5testserver.TestServer{ @@ -707,6 +863,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{ "test_foo": {}, }, @@ -747,6 +904,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -787,6 +945,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -827,6 +986,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -867,6 +1027,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -923,6 +1084,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -1011,6 +1173,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -1088,6 +1251,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedEphemeralResourcesSchemas: map[string]*tfprotov5.Schema{}, expectedListResourcesSchemas: map[string]*tfprotov5.Schema{}, @@ -1118,6 +1282,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { (&tf5testserver.TestServer{}).ProviderServer, (&tf5testserver.TestServer{}).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -1162,6 +1327,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -1201,6 +1367,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { (&tf5testserver.TestServer{}).ProviderServer, (&tf5testserver.TestServer{}).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -1245,6 +1412,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -1294,6 +1462,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov5.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov5.Schema{}, expectedDiagnostics: []*tfprotov5.Diagnostic{ { @@ -1336,6 +1505,10 @@ func TestMuxServerGetProviderSchema(t *testing.T) { t.Fatalf("unexpected error: %s", err) } + if diff := cmp.Diff(resp.ActionSchemas, testCase.expectedActionSchemas); diff != "" { + t.Errorf("action schemas didn't match expectations: %s", diff) + } + if diff := cmp.Diff(resp.DataSourceSchemas, testCase.expectedDataSourceSchemas); diff != "" { t.Errorf("data source schemas didn't match expectations: %s", diff) } diff --git a/tf5muxserver/mux_server_InvokeAction.go b/tf5muxserver/mux_server_InvokeAction.go new file mode 100644 index 0000000..3b954ab --- /dev/null +++ b/tf5muxserver/mux_server_InvokeAction.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tf5muxserver + +import ( + "context" + "slices" + + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + + "github.com/hashicorp/terraform-plugin-mux/internal/logging" +) + +func (s *muxServer) InvokeAction(ctx context.Context, req *tfprotov5.InvokeActionRequest) (*tfprotov5.InvokeActionServerStream, error) { + rpc := "InvokeAction" + ctx = logging.InitContext(ctx) + ctx = logging.RpcContext(ctx, rpc) + + server, diags, err := s.getActionServer(ctx, req.ActionType) + + if err != nil { + return nil, err + } + + if diagnosticsHasError(diags) { + return &tfprotov5.InvokeActionServerStream{ + Events: slices.Values([]tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + Diagnostics: diags, + }, + }, + }), + }, nil + } + + // TODO: Remove and call server.InvokeAction below directly once interface becomes required. + actionServer, ok := server.(tfprotov5.ActionServer) + if !ok { + resp := &tfprotov5.InvokeActionServerStream{ + Events: slices.Values([]tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "InvokeAction Not Implemented", + Detail: "An InvokeAction call was received by the provider, however the provider does not implement InvokeAction. " + + "Either upgrade the provider to a version that implements InvokeAction or this is a bug in Terraform that should be reported to the Terraform maintainers.", + }, + }, + }, + }, + }), + } + + return resp, nil + } + + ctx = logging.Tfprotov5ProviderServerContext(ctx, server) + logging.MuxTrace(ctx, "calling downstream server") + + return actionServer.InvokeAction(ctx, req) +} diff --git a/tf5muxserver/mux_server_InvokeAction_test.go b/tf5muxserver/mux_server_InvokeAction_test.go new file mode 100644 index 0000000..6835234 --- /dev/null +++ b/tf5muxserver/mux_server_InvokeAction_test.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tf5muxserver_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + + "github.com/hashicorp/terraform-plugin-mux/internal/tf5testserver" + "github.com/hashicorp/terraform-plugin-mux/tf5muxserver" +) + +func TestMuxServerInvokeAction(t *testing.T) { + t.Parallel() + + ctx := context.Background() + testServer1 := &tf5testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_action_server1": {}, + }, + }, + } + testServer2 := &tf5testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_action_server2": {}, + }, + }, + } + servers := []func() tfprotov5.ProviderServer{testServer1.ProviderServer, testServer2.ProviderServer} + muxServer, err := tf5muxserver.NewMuxServer(ctx, servers...) + + if err != nil { + t.Fatalf("unexpected error setting up factory: %s", err) + } + + //nolint:staticcheck // Intentionally verifying interface implementation + actionServer, ok := muxServer.ProviderServer().(tfprotov5.ProviderServerWithActions) + if !ok { + t.Fatal("muxServer should implement tfprotov5.ProviderServerWithActions") + } + + _, err = actionServer.InvokeAction(ctx, &tfprotov5.InvokeActionRequest{ + ActionType: "test_action_server1", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !testServer1.InvokeActionCalled["test_action_server1"] { + t.Errorf("expected test_action_server1 InvokeAction to be called on server1") + } + + if testServer2.InvokeActionCalled["test_action_server1"] { + t.Errorf("unexpected test_action_server1 InvokeAction called on server2") + } + + _, err = actionServer.InvokeAction(ctx, &tfprotov5.InvokeActionRequest{ + ActionType: "test_action_server2", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if testServer1.InvokeActionCalled["test_action_server2"] { + t.Errorf("unexpected test_action_server2 InvokeAction called on server1") + } + + if !testServer2.InvokeActionCalled["test_action_server2"] { + t.Errorf("expected test_action_server2 InvokeAction to be called on server2") + } +} diff --git a/tf5muxserver/mux_server_ListResource.go b/tf5muxserver/mux_server_ListResource.go index 3b6be28..b36918b 100644 --- a/tf5muxserver/mux_server_ListResource.go +++ b/tf5muxserver/mux_server_ListResource.go @@ -37,8 +37,7 @@ func (s *muxServer) ListResource(ctx context.Context, req *tfprotov5.ListResourc } // TODO: Remove and call server.ListResource below directly once interface becomes required. - //nolint:staticcheck // Intentionally verifying interface implementation - listResourceServer, ok := server.(tfprotov5.ProviderServerWithListResource) + listResourceServer, ok := server.(tfprotov5.ListResourceServer) if !ok { resp := &tfprotov5.ListResourceServerStream{ Results: slices.Values([]tfprotov5.ListResourceResult{ diff --git a/tf5muxserver/mux_server_PlanAction.go b/tf5muxserver/mux_server_PlanAction.go new file mode 100644 index 0000000..0f1b343 --- /dev/null +++ b/tf5muxserver/mux_server_PlanAction.go @@ -0,0 +1,52 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tf5muxserver + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + + "github.com/hashicorp/terraform-plugin-mux/internal/logging" +) + +func (s *muxServer) PlanAction(ctx context.Context, req *tfprotov5.PlanActionRequest) (*tfprotov5.PlanActionResponse, error) { + rpc := "PlanAction" + ctx = logging.InitContext(ctx) + ctx = logging.RpcContext(ctx, rpc) + + server, diags, err := s.getActionServer(ctx, req.ActionType) + + if err != nil { + return nil, err + } + + if diagnosticsHasError(diags) { + return &tfprotov5.PlanActionResponse{ + Diagnostics: diags, + }, nil + } + + // TODO: Remove and call server.PlanAction below directly once interface becomes required. + actionServer, ok := server.(tfprotov5.ActionServer) + if !ok { + resp := &tfprotov5.PlanActionResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "PlanAction Not Implemented", + Detail: "A PlanAction call was received by the provider, however the provider does not implement PlanAction. " + + "Either upgrade the provider to a version that implements PlanAction or this is a bug in Terraform that should be reported to the Terraform maintainers.", + }, + }, + } + + return resp, nil + } + + ctx = logging.Tfprotov5ProviderServerContext(ctx, server) + logging.MuxTrace(ctx, "calling downstream server") + + return actionServer.PlanAction(ctx, req) +} diff --git a/tf5muxserver/mux_server_PlanAction_test.go b/tf5muxserver/mux_server_PlanAction_test.go new file mode 100644 index 0000000..8c9778c --- /dev/null +++ b/tf5muxserver/mux_server_PlanAction_test.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tf5muxserver_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + + "github.com/hashicorp/terraform-plugin-mux/internal/tf5testserver" + "github.com/hashicorp/terraform-plugin-mux/tf5muxserver" +) + +func TestMuxServerPlanAction(t *testing.T) { + t.Parallel() + + ctx := context.Background() + testServer1 := &tf5testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_action_server1": {}, + }, + }, + } + testServer2 := &tf5testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_action_server2": {}, + }, + }, + } + servers := []func() tfprotov5.ProviderServer{testServer1.ProviderServer, testServer2.ProviderServer} + muxServer, err := tf5muxserver.NewMuxServer(ctx, servers...) + + if err != nil { + t.Fatalf("unexpected error setting up factory: %s", err) + } + + //nolint:staticcheck // Intentionally verifying interface implementation + actionServer, ok := muxServer.ProviderServer().(tfprotov5.ProviderServerWithActions) + if !ok { + t.Fatal("muxServer should implement tfprotov5.ProviderServerWithActions") + } + + _, err = actionServer.PlanAction(ctx, &tfprotov5.PlanActionRequest{ + ActionType: "test_action_server1", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !testServer1.PlanActionCalled["test_action_server1"] { + t.Errorf("expected test_action_server1 PlanAction to be called on server1") + } + + if testServer2.PlanActionCalled["test_action_server1"] { + t.Errorf("unexpected test_action_server1 PlanAction called on server2") + } + + _, err = actionServer.PlanAction(ctx, &tfprotov5.PlanActionRequest{ + ActionType: "test_action_server2", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if testServer1.PlanActionCalled["test_action_server2"] { + t.Errorf("unexpected test_action_server2 PlanAction called on server1") + } + + if !testServer2.PlanActionCalled["test_action_server2"] { + t.Errorf("expected test_action_server2 PlanAction to be called on server2") + } +} diff --git a/tf5muxserver/mux_server_ValidateListResourceConfig.go b/tf5muxserver/mux_server_ValidateListResourceConfig.go index d50b4dc..827cbef 100644 --- a/tf5muxserver/mux_server_ValidateListResourceConfig.go +++ b/tf5muxserver/mux_server_ValidateListResourceConfig.go @@ -29,8 +29,7 @@ func (s *muxServer) ValidateListResourceConfig(ctx context.Context, req *tfproto } // TODO: Remove and call server.ValidateListResourceConfig below directly once interface becomes required. - //nolint:staticcheck // Intentionally verifying interface implementation - listResourceServer, ok := server.(tfprotov5.ProviderServerWithListResource) + listResourceServer, ok := server.(tfprotov5.ListResourceServer) if !ok { resp := &tfprotov5.ValidateListResourceConfigResponse{ Diagnostics: []*tfprotov5.Diagnostic{ diff --git a/tf5to6server/tf5to6server.go b/tf5to6server/tf5to6server.go index bafa42e..111735f 100644 --- a/tf5to6server/tf5to6server.go +++ b/tf5to6server/tf5to6server.go @@ -284,8 +284,7 @@ func (s v5tov6Server) ValidateResourceConfig(ctx context.Context, req *tfprotov6 func (s v5tov6Server) ValidateListResourceConfig(ctx context.Context, req *tfprotov6.ValidateListResourceConfigRequest) (*tfprotov6.ValidateListResourceConfigResponse, error) { // TODO: Remove and call s.v5Server.ValidateListResourceConfig below directly once interface becomes required - //nolint:staticcheck // Intentionally verifying interface implementation - listResourceServer, ok := s.v5Server.(tfprotov5.ProviderServerWithListResource) + listResourceServer, ok := s.v5Server.(tfprotov5.ListResourceServer) if !ok { v6Resp := &tfprotov6.ValidateListResourceConfigResponse{ Diagnostics: []*tfprotov6.Diagnostic{ @@ -313,8 +312,7 @@ func (s v5tov6Server) ValidateListResourceConfig(ctx context.Context, req *tfpro func (s v5tov6Server) ListResource(ctx context.Context, req *tfprotov6.ListResourceRequest) (*tfprotov6.ListResourceServerStream, error) { // TODO: Remove and call s.v5Server.ListResource below directly once interface becomes required - //nolint:staticcheck // Intentionally verifying interface implementation - listResourceServer, ok := s.v5Server.(tfprotov5.ProviderServerWithListResource) + listResourceServer, ok := s.v5Server.(tfprotov5.ListResourceServer) if !ok { v6Resp := &tfprotov6.ListResourceServerStream{ Results: slices.Values([]tfprotov6.ListResourceResult{ @@ -343,3 +341,67 @@ func (s v5tov6Server) ListResource(ctx context.Context, req *tfprotov6.ListResou return tfprotov5tov6.ListResourceServerStream(v5Resp), nil } + +func (s v5tov6Server) PlanAction(ctx context.Context, req *tfprotov6.PlanActionRequest) (*tfprotov6.PlanActionResponse, error) { + // TODO: Remove and call s.v5Server.PlanAction below directly once interface becomes required + actionServer, ok := s.v5Server.(tfprotov5.ActionServer) + if !ok { + v6Resp := &tfprotov6.PlanActionResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "PlanAction Not Implemented", + Detail: "A PlanAction call was received by the provider, however the provider does not implement the RPC. " + + "Either upgrade the provider to a version that implements PlanAction or this is a bug in Terraform that should be reported to the Terraform maintainers.", + }, + }, + } + + return v6Resp, nil + } + + v5Req := tfprotov6tov5.PlanActionRequest(req) + + // v5Resp, err := s.v5Server.PlanAction(ctx, v5Req) + v5Resp, err := actionServer.PlanAction(ctx, v5Req) + if err != nil { + return nil, err + } + + return tfprotov5tov6.PlanActionResponse(v5Resp), nil +} + +func (s v5tov6Server) InvokeAction(ctx context.Context, req *tfprotov6.InvokeActionRequest) (*tfprotov6.InvokeActionServerStream, error) { + // TODO: Remove and call s.v5Server.InvokeAction below directly once interface becomes required + actionServer, ok := s.v5Server.(tfprotov5.ActionServer) + if !ok { + v6Resp := &tfprotov6.InvokeActionServerStream{ + Events: slices.Values([]tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "InvokeAction Not Implemented", + Detail: "An InvokeAction call was received by the provider, however the provider does not implement the RPC. " + + "Either upgrade the provider to a version that implements InvokeAction or this is a bug in Terraform that should be reported to the Terraform maintainers.", + }, + }, + }, + }, + }), + } + + return v6Resp, nil + } + + v5Req := tfprotov6tov5.InvokeActionRequest(req) + + // v5Resp, err := s.v5Server.InvokeAction(ctx, v5Req) + v5Resp, err := actionServer.InvokeAction(ctx, v5Req) + if err != nil { + return nil, err + } + + return tfprotov5tov6.InvokeActionServerStream(v5Resp), nil +} diff --git a/tf5to6server/tf5to6server_test.go b/tf5to6server/tf5to6server_test.go index 1fdb2ed..374b345 100644 --- a/tf5to6server/tf5to6server_test.go +++ b/tf5to6server/tf5to6server_test.go @@ -26,6 +26,11 @@ func TestUpgradeServer(t *testing.T) { "compatible": { v5Server: (&tf5testserver.TestServer{ GetProviderSchemaResponse: &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_action": { + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + }, DataSourceSchemas: map[string]*tfprotov5.Schema{ "test_data_source": {}, }, @@ -35,6 +40,9 @@ func TestUpgradeServer(t *testing.T) { Functions: map[string]*tfprotov5.Function{ "test_function": {}, }, + ListResourceSchemas: map[string]*tfprotov5.Schema{ + "test_list_resource": {}, + }, Provider: &tfprotov5.Schema{ Block: &tfprotov5.SchemaBlock{ Attributes: []*tfprotov5.SchemaAttribute{ @@ -131,7 +139,7 @@ func TestUpgradeServer(t *testing.T) { } } -func TestV6ToV5ServerApplyResourceChange(t *testing.T) { +func TestV5ToV6ServerApplyResourceChange(t *testing.T) { t.Parallel() ctx := context.Background() @@ -162,7 +170,7 @@ func TestV6ToV5ServerApplyResourceChange(t *testing.T) { } } -func TestV6ToV5ServerCallFunction(t *testing.T) { +func TestV5ToV6ServerCallFunction(t *testing.T) { t.Parallel() ctx := context.Background() @@ -193,7 +201,7 @@ func TestV6ToV5ServerCallFunction(t *testing.T) { } } -func TestV6ToV5ServerCloseEphemeralResource(t *testing.T) { +func TestV5ToV6ServerCloseEphemeralResource(t *testing.T) { t.Parallel() ctx := context.Background() @@ -224,7 +232,7 @@ func TestV6ToV5ServerCloseEphemeralResource(t *testing.T) { } } -func TestV6ToV5ServerConfigureProvider(t *testing.T) { +func TestV5ToV6ServerConfigureProvider(t *testing.T) { t.Parallel() ctx := context.Background() @@ -253,7 +261,7 @@ func TestV6ToV5ServerConfigureProvider(t *testing.T) { } } -func TestV6ToV5ServerGetFunctions(t *testing.T) { +func TestV5ToV6ServerGetFunctions(t *testing.T) { t.Parallel() ctx := context.Background() @@ -282,7 +290,7 @@ func TestV6ToV5ServerGetFunctions(t *testing.T) { } } -func TestV6ToV5ServerGetMetadata(t *testing.T) { +func TestV5ToV6ServerGetMetadata(t *testing.T) { t.Parallel() ctx := context.Background() @@ -313,7 +321,7 @@ func TestV6ToV5ServerGetMetadata(t *testing.T) { } } -func TestV6ToV5ServerGetProviderSchema(t *testing.T) { +func TestV5ToV6ServerGetProviderSchema(t *testing.T) { t.Parallel() ctx := context.Background() @@ -342,7 +350,7 @@ func TestV6ToV5ServerGetProviderSchema(t *testing.T) { } } -func TestV6ToV5ServerGetResourceIdentitySchemas(t *testing.T) { +func TestV5ToV6ServerGetResourceIdentitySchemas(t *testing.T) { t.Parallel() ctx := context.Background() @@ -367,7 +375,7 @@ func TestV6ToV5ServerGetResourceIdentitySchemas(t *testing.T) { } } -func TestV6ToV5ServerImportResourceState(t *testing.T) { +func TestV5ToV6ServerImportResourceState(t *testing.T) { t.Parallel() ctx := context.Background() @@ -429,7 +437,7 @@ func TestV5ToV6ServerMoveResourceState(t *testing.T) { } } -func TestV6ToV5ServerOpenEphemeralResource(t *testing.T) { +func TestV5ToV6ServerOpenEphemeralResource(t *testing.T) { t.Parallel() ctx := context.Background() @@ -460,7 +468,7 @@ func TestV6ToV5ServerOpenEphemeralResource(t *testing.T) { } } -func TestV6ToV5ServerPlanResourceChange(t *testing.T) { +func TestV5ToV6ServerPlanResourceChange(t *testing.T) { t.Parallel() ctx := context.Background() @@ -491,7 +499,7 @@ func TestV6ToV5ServerPlanResourceChange(t *testing.T) { } } -func TestV6ToV5ServerReadDataSource(t *testing.T) { +func TestV5ToV6ServerReadDataSource(t *testing.T) { t.Parallel() ctx := context.Background() @@ -522,7 +530,7 @@ func TestV6ToV5ServerReadDataSource(t *testing.T) { } } -func TestV6ToV5ServerReadResource(t *testing.T) { +func TestV5ToV6ServerReadResource(t *testing.T) { t.Parallel() ctx := context.Background() @@ -553,7 +561,7 @@ func TestV6ToV5ServerReadResource(t *testing.T) { } } -func TestV6ToV5ServerRenewEphemeralResource(t *testing.T) { +func TestV5ToV6ServerRenewEphemeralResource(t *testing.T) { t.Parallel() ctx := context.Background() @@ -584,7 +592,7 @@ func TestV6ToV5ServerRenewEphemeralResource(t *testing.T) { } } -func TestV6ToV5ServerStopProvider(t *testing.T) { +func TestV5ToV6ServerStopProvider(t *testing.T) { t.Parallel() ctx := context.Background() @@ -613,7 +621,7 @@ func TestV6ToV5ServerStopProvider(t *testing.T) { } } -func TestV6ToV5ServerUpgradeResourceState(t *testing.T) { +func TestV5ToV6ServerUpgradeResourceState(t *testing.T) { t.Parallel() ctx := context.Background() @@ -644,7 +652,7 @@ func TestV6ToV5ServerUpgradeResourceState(t *testing.T) { } } -func TestV6ToV5ServerUpgradeResourceIdentity(t *testing.T) { +func TestV5ToV6ServerUpgradeResourceIdentity(t *testing.T) { t.Parallel() ctx := context.Background() @@ -675,7 +683,7 @@ func TestV6ToV5ServerUpgradeResourceIdentity(t *testing.T) { } } -func TestV6ToV5ServerValidateDataResourceConfig(t *testing.T) { +func TestV5ToV6ServerValidateDataResourceConfig(t *testing.T) { t.Parallel() ctx := context.Background() @@ -706,7 +714,7 @@ func TestV6ToV5ServerValidateDataResourceConfig(t *testing.T) { } } -func TestV6ToV5ServerValidateEphemeralResourceConfig(t *testing.T) { +func TestV5ToV6ServerValidateEphemeralResourceConfig(t *testing.T) { t.Parallel() ctx := context.Background() @@ -737,7 +745,7 @@ func TestV6ToV5ServerValidateEphemeralResourceConfig(t *testing.T) { } } -func TestV6ToV5ServerValidateProviderConfig(t *testing.T) { +func TestV5ToV6ServerValidateProviderConfig(t *testing.T) { t.Parallel() ctx := context.Background() @@ -766,7 +774,7 @@ func TestV6ToV5ServerValidateProviderConfig(t *testing.T) { } } -func TestV6ToV5ServerValidateResourceConfig(t *testing.T) { +func TestV5ToV6ServerValidateResourceConfig(t *testing.T) { t.Parallel() ctx := context.Background() @@ -870,3 +878,81 @@ func TestV5ToV6ServerListResource(t *testing.T) { t.Errorf("expected test_list_resource ListResourceConfig to be called") } } + +func TestV5ToV6ServerPlanAction(t *testing.T) { + t.Parallel() + + ctx := context.Background() + v5server := &tf5testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_action": { + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + }, + }, + } + + v6server, err := tf5to6server.UpgradeServer(context.Background(), v5server.ProviderServer) + + if err != nil { + t.Fatalf("unexpected error downgrading server: %s", err) + } + + //nolint:staticcheck // Intentionally verifying interface implementation + actionServer, ok := v6server.(tfprotov6.ProviderServerWithActions) + if !ok { + t.Fatal("v6server should implement tfprotov6.ProviderServerWithActions") + } + + _, err = actionServer.PlanAction(ctx, &tfprotov6.PlanActionRequest{ + ActionType: "test_action", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !v5server.PlanActionCalled["test_action"] { + t.Errorf("expected test_action PlanAction to be called") + } +} + +func TestV5ToV6ServerInvokeAction(t *testing.T) { + t.Parallel() + + ctx := context.Background() + v5server := &tf5testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov5.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov5.ActionSchema{ + "test_action": { + Type: tfprotov5.UnlinkedActionSchemaType{}, + }, + }, + }, + } + + v6server, err := tf5to6server.UpgradeServer(context.Background(), v5server.ProviderServer) + + if err != nil { + t.Fatalf("unexpected error upgrading server: %s", err) + } + + //nolint:staticcheck // Intentionally verifying interface implementation + actionServer, ok := v6server.(tfprotov6.ProviderServerWithActions) + if !ok { + t.Fatal("v6server should implement tfprotov6.ProviderServerWithActions") + } + + _, err = actionServer.InvokeAction(ctx, &tfprotov6.InvokeActionRequest{ + ActionType: "test_action", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !v5server.InvokeActionCalled["test_action"] { + t.Errorf("expected test_action InvokeAction to be called") + } +} diff --git a/tf6muxserver/diagnostics.go b/tf6muxserver/diagnostics.go index 33d3f71..e713510 100644 --- a/tf6muxserver/diagnostics.go +++ b/tf6muxserver/diagnostics.go @@ -7,6 +7,27 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov6" ) +func actionDuplicateError(actionType string) *tfprotov6.Diagnostic { + return &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Invalid Provider Server Combination", + Detail: "The combined provider has multiple implementations of the same action type across underlying providers. " + + "Actions must be implemented by only one underlying provider. " + + "This is always an issue in the provider implementation and should be reported to the provider developers.\n\n" + + "Duplicate action: " + actionType, + } +} + +func actionMissingError(actionType string) *tfprotov6.Diagnostic { + return &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Action Not Implemented", + Detail: "The combined provider does not implement the requested action. " + + "This is always an issue in the provider implementation and should be reported to the provider developers.\n\n" + + "Missing action: " + actionType, + } +} + func dataSourceDuplicateError(typeName string) *tfprotov6.Diagnostic { return &tfprotov6.Diagnostic{ Severity: tfprotov6.DiagnosticSeverityError, diff --git a/tf6muxserver/mux_server.go b/tf6muxserver/mux_server.go index 320af12..8a46dad 100644 --- a/tf6muxserver/mux_server.go +++ b/tf6muxserver/mux_server.go @@ -20,6 +20,9 @@ var _ tfprotov6.ProviderServer = &muxServer{} // gRPC servers, routing requests to them as if they were a single server. It // should always be instantiated by calling NewMuxServer(). type muxServer struct { + // Routing for actions + actions map[string]tfprotov6.ProviderServer + // Routing for data source types dataSources map[string]tfprotov6.ProviderServer @@ -62,6 +65,41 @@ func (s *muxServer) ProviderServer() tfprotov6.ProviderServer { return s } +func (s *muxServer) getActionServer(ctx context.Context, actionType string) (tfprotov6.ProviderServer, []*tfprotov6.Diagnostic, error) { + s.serverDiscoveryMutex.RLock() + server, ok := s.actions[actionType] + discoveryComplete := s.serverDiscoveryComplete + s.serverDiscoveryMutex.RUnlock() + + if discoveryComplete { + if ok { + return server, s.serverDiscoveryDiagnostics, nil + } + + return nil, []*tfprotov6.Diagnostic{ + actionMissingError(actionType), + }, nil + } + + err := s.serverDiscovery(ctx) + + if err != nil || diagnosticsHasError(s.serverDiscoveryDiagnostics) { + return nil, s.serverDiscoveryDiagnostics, err + } + + s.serverDiscoveryMutex.RLock() + server, ok = s.actions[actionType] + s.serverDiscoveryMutex.RUnlock() + + if !ok { + return nil, []*tfprotov6.Diagnostic{ + actionMissingError(actionType), + }, nil + } + + return server, s.serverDiscoveryDiagnostics, nil +} + func (s *muxServer) getDataSourceServer(ctx context.Context, typeName string) (tfprotov6.ProviderServer, []*tfprotov6.Diagnostic, error) { s.serverDiscoveryMutex.RLock() server, ok := s.dataSources[typeName] @@ -240,7 +278,8 @@ func (s *muxServer) getResourceServer(ctx context.Context, typeName string) (tfp // serverDiscovery will populate the mux server "routing" for functions and // resource types by calling all underlying server GetMetadata RPC and falling // back to GetProviderSchema RPC. It is intended to only be called through -// getDataSourceServer, getEphemeralResourceServer, getListResourceServer, getFunctionServer, and getResourceServer. +// getActionServer, getDataSourceServer, getEphemeralResourceServer, getListResourceServer, +// getFunctionServer, and getResourceServer. // // The error return represents gRPC errors, which except for the GetMetadata // call returning the gRPC unimplemented error, is always returned. @@ -268,6 +307,16 @@ func (s *muxServer) serverDiscovery(ctx context.Context) error { // Collect all underlying server diagnostics, but skip early return. s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, metadataResp.Diagnostics...) + for _, serverAction := range metadataResp.Actions { + if _, ok := s.actions[serverAction.TypeName]; ok { + s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, actionDuplicateError(serverAction.TypeName)) + + continue + } + + s.actions[serverAction.TypeName] = server + } + for _, serverDataSource := range metadataResp.DataSources { if _, ok := s.dataSources[serverDataSource.TypeName]; ok { s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, dataSourceDuplicateError(serverDataSource.TypeName)) @@ -340,6 +389,16 @@ func (s *muxServer) serverDiscovery(ctx context.Context) error { // Collect all underlying server diagnostics, but skip early return. s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, providerSchemaResp.Diagnostics...) + for actionType := range providerSchemaResp.ActionSchemas { + if _, ok := s.actions[actionType]; ok { + s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, actionDuplicateError(actionType)) + + continue + } + + s.actions[actionType] = server + } + for typeName := range providerSchemaResp.DataSourceSchemas { if _, ok := s.dataSources[typeName]; ok { s.serverDiscoveryDiagnostics = append(s.serverDiscoveryDiagnostics, dataSourceDuplicateError(typeName)) @@ -404,6 +463,7 @@ func (s *muxServer) serverDiscovery(ctx context.Context) error { // // - All provider schemas exactly match // - All provider meta schemas exactly match +// - Only one provider implements each action // - Only one provider implements each managed resource // - Only one provider implements each data source // - Only one provider implements each function @@ -412,6 +472,7 @@ func (s *muxServer) serverDiscovery(ctx context.Context) error { // - Only one provider implements each resource identity func NewMuxServer(_ context.Context, servers ...func() tfprotov6.ProviderServer) (*muxServer, error) { result := muxServer{ + actions: make(map[string]tfprotov6.ProviderServer), dataSources: make(map[string]tfprotov6.ProviderServer), ephemeralResources: make(map[string]tfprotov6.ProviderServer), listResources: make(map[string]tfprotov6.ProviderServer), diff --git a/tf6muxserver/mux_server_GetMetadata.go b/tf6muxserver/mux_server_GetMetadata.go index 92c4c4f..b0e13d6 100644 --- a/tf6muxserver/mux_server_GetMetadata.go +++ b/tf6muxserver/mux_server_GetMetadata.go @@ -14,8 +14,8 @@ import ( // GetMetadata merges the metadata returned by the // tfprotov6.ProviderServers associated with muxServer into a single response. -// Resources and data sources must be returned from only one server or an error -// diagnostic is returned. +// Resources, data sources, ephemeral resources, list resources, actions, and functions must be returned +// from only one server or an error diagnostic is returned. func (s *muxServer) GetMetadata(ctx context.Context, req *tfprotov6.GetMetadataRequest) (*tfprotov6.GetMetadataResponse, error) { rpc := "GetMetadata" ctx = logging.InitContext(ctx) @@ -25,6 +25,7 @@ func (s *muxServer) GetMetadata(ctx context.Context, req *tfprotov6.GetMetadataR defer s.serverDiscoveryMutex.Unlock() resp := &tfprotov6.GetMetadataResponse{ + Actions: make([]tfprotov6.ActionMetadata, 0), DataSources: make([]tfprotov6.DataSourceMetadata, 0), EphemeralResources: make([]tfprotov6.EphemeralResourceMetadata, 0), ListResources: make([]tfprotov6.ListResourceMetadata, 0), @@ -45,6 +46,17 @@ func (s *muxServer) GetMetadata(ctx context.Context, req *tfprotov6.GetMetadataR resp.Diagnostics = append(resp.Diagnostics, serverResp.Diagnostics...) + for _, action := range serverResp.Actions { + if actionMetadataContainsTypeName(resp.Actions, action.TypeName) { + resp.Diagnostics = append(resp.Diagnostics, actionDuplicateError(action.TypeName)) + + continue + } + + s.actions[action.TypeName] = server + resp.Actions = append(resp.Actions, action) + } + for _, datasource := range serverResp.DataSources { if datasourceMetadataContainsTypeName(resp.DataSources, datasource.TypeName) { resp.Diagnostics = append(resp.Diagnostics, dataSourceDuplicateError(datasource.TypeName)) @@ -105,6 +117,16 @@ func (s *muxServer) GetMetadata(ctx context.Context, req *tfprotov6.GetMetadataR return resp, nil } +func actionMetadataContainsTypeName(metadatas []tfprotov6.ActionMetadata, typeName string) bool { + for _, metadata := range metadatas { + if typeName == metadata.TypeName { + return true + } + } + + return false +} + func datasourceMetadataContainsTypeName(metadatas []tfprotov6.DataSourceMetadata, typeName string) bool { for _, metadata := range metadatas { if typeName == metadata.TypeName { diff --git a/tf6muxserver/mux_server_GetMetadata_test.go b/tf6muxserver/mux_server_GetMetadata_test.go index dae8138..d9822ff 100644 --- a/tf6muxserver/mux_server_GetMetadata_test.go +++ b/tf6muxserver/mux_server_GetMetadata_test.go @@ -19,6 +19,7 @@ func TestMuxServerGetMetadata(t *testing.T) { testCases := map[string]struct { servers []func() tfprotov6.ProviderServer + expectedActions []tfprotov6.ActionMetadata expectedDataSources []tfprotov6.DataSourceMetadata expectedDiagnostics []*tfprotov6.Diagnostic expectedEphemeralResources []tfprotov6.EphemeralResourceMetadata @@ -31,6 +32,14 @@ func TestMuxServerGetMetadata(t *testing.T) { servers: []func() tfprotov6.ProviderServer{ (&tf6testserver.TestServer{ GetMetadataResponse: &tfprotov6.GetMetadataResponse{ + Actions: []tfprotov6.ActionMetadata{ + { + TypeName: "test_foo", + }, + { + TypeName: "test_bar", + }, + }, Resources: []tfprotov6.ResourceMetadata{ { TypeName: "test_foo", @@ -69,6 +78,11 @@ func TestMuxServerGetMetadata(t *testing.T) { }).ProviderServer, (&tf6testserver.TestServer{ GetMetadataResponse: &tfprotov6.GetMetadataResponse{ + Actions: []tfprotov6.ActionMetadata{ + { + TypeName: "test_quux", + }, + }, Resources: []tfprotov6.ResourceMetadata{ { TypeName: "test_quux", @@ -103,6 +117,17 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{ + { + TypeName: "test_foo", + }, + { + TypeName: "test_bar", + }, + { + TypeName: "test_quux", + }, + }, expectedResources: []tfprotov6.ResourceMetadata{ { TypeName: "test_foo", @@ -164,6 +189,53 @@ func TestMuxServerGetMetadata(t *testing.T) { PlanDestroy: true, }, }, + "duplicate-action": { + servers: []func() tfprotov6.ProviderServer{ + (&tf6testserver.TestServer{ + GetMetadataResponse: &tfprotov6.GetMetadataResponse{ + Actions: []tfprotov6.ActionMetadata{ + { + TypeName: "test_foo", + }, + }, + }, + }).ProviderServer, + (&tf6testserver.TestServer{ + GetMetadataResponse: &tfprotov6.GetMetadataResponse{ + Actions: []tfprotov6.ActionMetadata{ + { + TypeName: "test_foo", + }, + }, + }, + }).ProviderServer, + }, + expectedActions: []tfprotov6.ActionMetadata{ + { + TypeName: "test_foo", + }, + }, + expectedDataSources: []tfprotov6.DataSourceMetadata{}, + expectedDiagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Invalid Provider Server Combination", + Detail: "The combined provider has multiple implementations of the same action type across underlying providers. " + + "Actions must be implemented by only one underlying provider. " + + "This is always an issue in the provider implementation and should be reported to the provider developers.\n\n" + + "Duplicate action: test_foo", + }, + }, + expectedEphemeralResources: []tfprotov6.EphemeralResourceMetadata{}, + expectedListResources: []tfprotov6.ListResourceMetadata{}, + expectedFunctions: []tfprotov6.FunctionMetadata{}, + expectedResources: []tfprotov6.ResourceMetadata{}, + expectedServerCapabilities: &tfprotov6.ServerCapabilities{ + GetProviderSchemaOptional: true, + MoveResourceState: true, + PlanDestroy: true, + }, + }, "duplicate-data-source-type": { servers: []func() tfprotov6.ProviderServer{ (&tf6testserver.TestServer{ @@ -185,6 +257,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{}, expectedDataSources: []tfprotov6.DataSourceMetadata{ { TypeName: "test_foo", @@ -231,6 +304,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{}, expectedDataSources: []tfprotov6.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -277,6 +351,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{}, expectedDataSources: []tfprotov6.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -323,6 +398,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{}, expectedDataSources: []tfprotov6.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -369,6 +445,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{}, expectedDataSources: []tfprotov6.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -420,6 +497,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{}, expectedDataSources: []tfprotov6.DataSourceMetadata{}, expectedEphemeralResources: []tfprotov6.EphemeralResourceMetadata{}, expectedListResources: []tfprotov6.ListResourceMetadata{}, @@ -454,6 +532,7 @@ func TestMuxServerGetMetadata(t *testing.T) { (&tf6testserver.TestServer{}).ProviderServer, (&tf6testserver.TestServer{}).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{}, expectedDataSources: []tfprotov6.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -498,6 +577,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{}, expectedDataSources: []tfprotov6.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -537,6 +617,7 @@ func TestMuxServerGetMetadata(t *testing.T) { (&tf6testserver.TestServer{}).ProviderServer, (&tf6testserver.TestServer{}).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{}, expectedDataSources: []tfprotov6.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -581,6 +662,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{}, expectedDataSources: []tfprotov6.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -630,6 +712,7 @@ func TestMuxServerGetMetadata(t *testing.T) { }, }).ProviderServer, }, + expectedActions: []tfprotov6.ActionMetadata{}, expectedDataSources: []tfprotov6.DataSourceMetadata{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -672,6 +755,10 @@ func TestMuxServerGetMetadata(t *testing.T) { t.Fatalf("unexpected error: %s", err) } + if diff := cmp.Diff(resp.Actions, testCase.expectedActions); diff != "" { + t.Errorf("actions didn't match expectations: %s", diff) + } + if diff := cmp.Diff(resp.DataSources, testCase.expectedDataSources); diff != "" { t.Errorf("data sources didn't match expectations: %s", diff) } diff --git a/tf6muxserver/mux_server_GetProviderSchema.go b/tf6muxserver/mux_server_GetProviderSchema.go index 571bf00..5b46594 100644 --- a/tf6muxserver/mux_server_GetProviderSchema.go +++ b/tf6muxserver/mux_server_GetProviderSchema.go @@ -14,8 +14,8 @@ import ( // GetProviderSchema merges the schemas returned by the // tfprotov6.ProviderServers associated with muxServer into a single schema. -// Resources and data sources must be returned from only one server. Provider -// and ProviderMeta schemas must be identical between all servers. +// Resources, data sources, ephemeral resources, list resources, actions, and functions must be returned +// from only one server. Provider and ProviderMeta schemas must be identical between all servers. func (s *muxServer) GetProviderSchema(ctx context.Context, req *tfprotov6.GetProviderSchemaRequest) (*tfprotov6.GetProviderSchemaResponse, error) { rpc := "GetProviderSchema" ctx = logging.InitContext(ctx) @@ -25,6 +25,7 @@ func (s *muxServer) GetProviderSchema(ctx context.Context, req *tfprotov6.GetPro defer s.serverDiscoveryMutex.Unlock() resp := &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: make(map[string]*tfprotov6.ActionSchema), DataSourceSchemas: make(map[string]*tfprotov6.Schema), ListResourceSchemas: make(map[string]*tfprotov6.Schema), EphemeralResourceSchemas: make(map[string]*tfprotov6.Schema), @@ -75,6 +76,17 @@ func (s *muxServer) GetProviderSchema(ctx context.Context, req *tfprotov6.GetPro } } + for actionType, schema := range serverResp.ActionSchemas { + if _, ok := resp.ActionSchemas[actionType]; ok { + resp.Diagnostics = append(resp.Diagnostics, actionDuplicateError(actionType)) + + continue + } + + s.actions[actionType] = server + resp.ActionSchemas[actionType] = schema + } + for resourceType, schema := range serverResp.ResourceSchemas { if _, ok := resp.ResourceSchemas[resourceType]; ok { resp.Diagnostics = append(resp.Diagnostics, resourceDuplicateError(resourceType)) diff --git a/tf6muxserver/mux_server_GetProviderSchema_test.go b/tf6muxserver/mux_server_GetProviderSchema_test.go index bd939f9..2c3578c 100644 --- a/tf6muxserver/mux_server_GetProviderSchema_test.go +++ b/tf6muxserver/mux_server_GetProviderSchema_test.go @@ -20,6 +20,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { testCases := map[string]struct { servers []func() tfprotov6.ProviderServer + expectedActionSchemas map[string]*tfprotov6.ActionSchema expectedDataSourceSchemas map[string]*tfprotov6.Schema expectedDiagnostics []*tfprotov6.Diagnostic expectedEphemeralResourcesSchemas map[string]*tfprotov6.Schema @@ -130,6 +131,26 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, }, + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_foo": { + Schema: &tfprotov6.Schema{ + Version: 1, + Block: &tfprotov6.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "current_time", + Type: tftypes.String, + Computed: true, + Description: "the current time in RFC 3339 format", + DescriptionKind: tfprotov6.StringKindPlain, + }, + }, + }, + }, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + }, DataSourceSchemas: map[string]*tfprotov6.Schema{ "test_foo": { Version: 1, @@ -315,6 +336,44 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, }, + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_bar": { + Schema: &tfprotov6.Schema{ + Version: 1, + Block: &tfprotov6.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "a", + Type: tftypes.Number, + Computed: true, + Description: "some field that's set by the provider", + DescriptionKind: tfprotov6.StringKindMarkdown, + }, + }, + }, + }, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + "test_quux": { + Schema: &tfprotov6.Schema{ + Version: 1, + Block: &tfprotov6.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "abc", + Type: tftypes.Number, + Computed: true, + Description: "some other field that's set by the provider", + DescriptionKind: tfprotov6.StringKindMarkdown, + }, + }, + }, + }, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + }, DataSourceSchemas: map[string]*tfprotov6.Schema{ "test_bar": { Version: 1, @@ -512,6 +571,62 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_foo": { + Schema: &tfprotov6.Schema{ + Version: 1, + Block: &tfprotov6.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "current_time", + Type: tftypes.String, + Computed: true, + Description: "the current time in RFC 3339 format", + DescriptionKind: tfprotov6.StringKindPlain, + }, + }, + }, + }, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + "test_bar": { + Schema: &tfprotov6.Schema{ + Version: 1, + Block: &tfprotov6.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "a", + Type: tftypes.Number, + Computed: true, + Description: "some field that's set by the provider", + DescriptionKind: tfprotov6.StringKindMarkdown, + }, + }, + }, + }, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + "test_quux": { + Schema: &tfprotov6.Schema{ + Version: 1, + Block: &tfprotov6.SchemaBlock{ + Version: 1, + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "abc", + Type: tftypes.Number, + Computed: true, + Description: "some other field that's set by the provider", + DescriptionKind: tfprotov6.StringKindMarkdown, + }, + }, + }, + }, + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + }, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{ "test_foo": { Version: 1, @@ -690,6 +805,47 @@ func TestMuxServerGetProviderSchema(t *testing.T) { PlanDestroy: true, }, }, + "duplicate-action": { + servers: []func() tfprotov6.ProviderServer{ + (&tf6testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_foo": {}, + }, + }, + }).ProviderServer, + (&tf6testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_foo": {}, + }, + }, + }).ProviderServer, + }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_foo": {}, + }, + expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, + expectedDiagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Invalid Provider Server Combination", + Detail: "The combined provider has multiple implementations of the same action type across underlying providers. " + + "Actions must be implemented by only one underlying provider. " + + "This is always an issue in the provider implementation and should be reported to the provider developers.\n\n" + + "Duplicate action: test_foo", + }, + }, + expectedEphemeralResourcesSchemas: map[string]*tfprotov6.Schema{}, + expectedListResourcesSchemas: map[string]*tfprotov6.Schema{}, + expectedFunctions: map[string]*tfprotov6.Function{}, + expectedResourceSchemas: map[string]*tfprotov6.Schema{}, + expectedServerCapabilities: &tfprotov6.ServerCapabilities{ + GetProviderSchemaOptional: true, + MoveResourceState: true, + PlanDestroy: true, + }, + }, "duplicate-data-source-type": { servers: []func() tfprotov6.ProviderServer{ (&tf6testserver.TestServer{ @@ -707,6 +863,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{ "test_foo": {}, }, @@ -747,6 +904,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -787,6 +945,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -827,6 +986,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -867,6 +1027,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -923,6 +1084,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -1011,6 +1173,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -1087,6 +1250,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedEphemeralResourcesSchemas: map[string]*tfprotov6.Schema{}, expectedListResourcesSchemas: map[string]*tfprotov6.Schema{}, @@ -1117,6 +1281,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { (&tf6testserver.TestServer{}).ProviderServer, (&tf6testserver.TestServer{}).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -1161,6 +1326,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -1200,6 +1366,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { (&tf6testserver.TestServer{}).ProviderServer, (&tf6testserver.TestServer{}).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -1244,6 +1411,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -1293,6 +1461,7 @@ func TestMuxServerGetProviderSchema(t *testing.T) { }, }).ProviderServer, }, + expectedActionSchemas: map[string]*tfprotov6.ActionSchema{}, expectedDataSourceSchemas: map[string]*tfprotov6.Schema{}, expectedDiagnostics: []*tfprotov6.Diagnostic{ { @@ -1335,6 +1504,10 @@ func TestMuxServerGetProviderSchema(t *testing.T) { t.Fatalf("unexpected error: %s", err) } + if diff := cmp.Diff(resp.ActionSchemas, testCase.expectedActionSchemas); diff != "" { + t.Errorf("action schemas didn't match expectations: %s", diff) + } + if diff := cmp.Diff(resp.DataSourceSchemas, testCase.expectedDataSourceSchemas); diff != "" { t.Errorf("data source schemas didn't match expectations: %s", diff) } diff --git a/tf6muxserver/mux_server_InvokeAction.go b/tf6muxserver/mux_server_InvokeAction.go new file mode 100644 index 0000000..0f42db2 --- /dev/null +++ b/tf6muxserver/mux_server_InvokeAction.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tf6muxserver + +import ( + "context" + "slices" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "github.com/hashicorp/terraform-plugin-mux/internal/logging" +) + +func (s *muxServer) InvokeAction(ctx context.Context, req *tfprotov6.InvokeActionRequest) (*tfprotov6.InvokeActionServerStream, error) { + rpc := "InvokeAction" + ctx = logging.InitContext(ctx) + ctx = logging.RpcContext(ctx, rpc) + + server, diags, err := s.getActionServer(ctx, req.ActionType) + + if err != nil { + return nil, err + } + + if diagnosticsHasError(diags) { + return &tfprotov6.InvokeActionServerStream{ + Events: slices.Values([]tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + Diagnostics: diags, + }, + }, + }), + }, nil + } + + // TODO: Remove and call server.InvokeAction below directly once interface becomes required. + actionServer, ok := server.(tfprotov6.ActionServer) + if !ok { + resp := &tfprotov6.InvokeActionServerStream{ + Events: slices.Values([]tfprotov6.InvokeActionEvent{ + { + Type: tfprotov6.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "InvokeAction Not Implemented", + Detail: "An InvokeAction call was received by the provider, however the provider does not implement InvokeAction. " + + "Either upgrade the provider to a version that implements InvokeAction or this is a bug in Terraform that should be reported to the Terraform maintainers.", + }, + }, + }, + }, + }), + } + + return resp, nil + } + + ctx = logging.Tfprotov6ProviderServerContext(ctx, server) + logging.MuxTrace(ctx, "calling downstream server") + + return actionServer.InvokeAction(ctx, req) +} diff --git a/tf6muxserver/mux_server_InvokeAction_test.go b/tf6muxserver/mux_server_InvokeAction_test.go new file mode 100644 index 0000000..26f7ced --- /dev/null +++ b/tf6muxserver/mux_server_InvokeAction_test.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tf6muxserver_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "github.com/hashicorp/terraform-plugin-mux/internal/tf6testserver" + "github.com/hashicorp/terraform-plugin-mux/tf6muxserver" +) + +func TestMuxServerInvokeAction(t *testing.T) { + t.Parallel() + + ctx := context.Background() + testServer1 := &tf6testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_action_server1": {}, + }, + }, + } + testServer2 := &tf6testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_action_server2": {}, + }, + }, + } + servers := []func() tfprotov6.ProviderServer{testServer1.ProviderServer, testServer2.ProviderServer} + muxServer, err := tf6muxserver.NewMuxServer(ctx, servers...) + + if err != nil { + t.Fatalf("unexpected error setting up factory: %s", err) + } + + //nolint:staticcheck // Intentionally verifying interface implementation + actionServer, ok := muxServer.ProviderServer().(tfprotov6.ProviderServerWithActions) + if !ok { + t.Fatal("muxServer should implement tfprotov6.ProviderServerWithActions") + } + + _, err = actionServer.InvokeAction(ctx, &tfprotov6.InvokeActionRequest{ + ActionType: "test_action_server1", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !testServer1.InvokeActionCalled["test_action_server1"] { + t.Errorf("expected test_action_server1 InvokeAction to be called on server1") + } + + if testServer2.InvokeActionCalled["test_action_server1"] { + t.Errorf("unexpected test_action_server1 InvokeAction called on server2") + } + + _, err = actionServer.InvokeAction(ctx, &tfprotov6.InvokeActionRequest{ + ActionType: "test_action_server2", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if testServer1.InvokeActionCalled["test_action_server2"] { + t.Errorf("unexpected test_action_server2 InvokeAction called on server1") + } + + if !testServer2.InvokeActionCalled["test_action_server2"] { + t.Errorf("expected test_action_server2 InvokeAction to be called on server2") + } +} diff --git a/tf6muxserver/mux_server_ListResource.go b/tf6muxserver/mux_server_ListResource.go index 1bd5bf1..245039a 100644 --- a/tf6muxserver/mux_server_ListResource.go +++ b/tf6muxserver/mux_server_ListResource.go @@ -5,9 +5,10 @@ package tf6muxserver import ( "context" - "github.com/hashicorp/terraform-plugin-go/tfprotov6" "slices" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-mux/internal/logging" ) @@ -36,8 +37,7 @@ func (s *muxServer) ListResource(ctx context.Context, req *tfprotov6.ListResourc } // TODO: Remove and call server.ListResource below directly once interface becomes required. - //nolint:staticcheck // Intentionally verifying interface implementation - listResourceServer, ok := server.(tfprotov6.ProviderServerWithListResource) + listResourceServer, ok := server.(tfprotov6.ListResourceServer) if !ok { resp := &tfprotov6.ListResourceServerStream{ Results: slices.Values([]tfprotov6.ListResourceResult{ diff --git a/tf6muxserver/mux_server_PlanAction.go b/tf6muxserver/mux_server_PlanAction.go new file mode 100644 index 0000000..72d5f2a --- /dev/null +++ b/tf6muxserver/mux_server_PlanAction.go @@ -0,0 +1,52 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tf6muxserver + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "github.com/hashicorp/terraform-plugin-mux/internal/logging" +) + +func (s *muxServer) PlanAction(ctx context.Context, req *tfprotov6.PlanActionRequest) (*tfprotov6.PlanActionResponse, error) { + rpc := "PlanAction" + ctx = logging.InitContext(ctx) + ctx = logging.RpcContext(ctx, rpc) + + server, diags, err := s.getActionServer(ctx, req.ActionType) + + if err != nil { + return nil, err + } + + if diagnosticsHasError(diags) { + return &tfprotov6.PlanActionResponse{ + Diagnostics: diags, + }, nil + } + + // TODO: Remove and call server.PlanAction below directly once interface becomes required. + actionServer, ok := server.(tfprotov6.ActionServer) + if !ok { + resp := &tfprotov6.PlanActionResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "PlanAction Not Implemented", + Detail: "A PlanAction call was received by the provider, however the provider does not implement PlanAction. " + + "Either upgrade the provider to a version that implements PlanAction or this is a bug in Terraform that should be reported to the Terraform maintainers.", + }, + }, + } + + return resp, nil + } + + ctx = logging.Tfprotov6ProviderServerContext(ctx, server) + logging.MuxTrace(ctx, "calling downstream server") + + return actionServer.PlanAction(ctx, req) +} diff --git a/tf6muxserver/mux_server_PlanAction_test.go b/tf6muxserver/mux_server_PlanAction_test.go new file mode 100644 index 0000000..bfdfad5 --- /dev/null +++ b/tf6muxserver/mux_server_PlanAction_test.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tf6muxserver_test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "github.com/hashicorp/terraform-plugin-mux/internal/tf6testserver" + "github.com/hashicorp/terraform-plugin-mux/tf6muxserver" +) + +func TestMuxServerPlanAction(t *testing.T) { + t.Parallel() + + ctx := context.Background() + testServer1 := &tf6testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_action_server1": {}, + }, + }, + } + testServer2 := &tf6testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_action_server2": {}, + }, + }, + } + servers := []func() tfprotov6.ProviderServer{testServer1.ProviderServer, testServer2.ProviderServer} + muxServer, err := tf6muxserver.NewMuxServer(ctx, servers...) + + if err != nil { + t.Fatalf("unexpected error setting up factory: %s", err) + } + + //nolint:staticcheck // Intentionally verifying interface implementation + actionServer, ok := muxServer.ProviderServer().(tfprotov6.ProviderServerWithActions) + if !ok { + t.Fatal("muxServer should implement tfprotov6.ProviderServerWithActions") + } + + _, err = actionServer.PlanAction(ctx, &tfprotov6.PlanActionRequest{ + ActionType: "test_action_server1", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !testServer1.PlanActionCalled["test_action_server1"] { + t.Errorf("expected test_action_server1 PlanAction to be called on server1") + } + + if testServer2.PlanActionCalled["test_action_server1"] { + t.Errorf("unexpected test_action_server1 PlanAction called on server2") + } + + _, err = actionServer.PlanAction(ctx, &tfprotov6.PlanActionRequest{ + ActionType: "test_action_server2", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if testServer1.PlanActionCalled["test_action_server2"] { + t.Errorf("unexpected test_action_server2 PlanAction called on server1") + } + + if !testServer2.PlanActionCalled["test_action_server2"] { + t.Errorf("expected test_action_server2 PlanAction to be called on server2") + } +} diff --git a/tf6muxserver/mux_server_ValidateListResourceConfig.go b/tf6muxserver/mux_server_ValidateListResourceConfig.go index 3f2a844..5ebc544 100644 --- a/tf6muxserver/mux_server_ValidateListResourceConfig.go +++ b/tf6muxserver/mux_server_ValidateListResourceConfig.go @@ -5,6 +5,7 @@ package tf6muxserver import ( "context" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-mux/internal/logging" @@ -28,8 +29,7 @@ func (s *muxServer) ValidateListResourceConfig(ctx context.Context, req *tfproto } // TODO: Remove and call server.ValidateListResourceConfig below directly once interface becomes required. - //nolint:staticcheck // Intentionally verifying interface implementation - listResourceServer, ok := server.(tfprotov6.ProviderServerWithListResource) + listResourceServer, ok := server.(tfprotov6.ListResourceServer) if !ok { resp := &tfprotov6.ValidateListResourceConfigResponse{ Diagnostics: []*tfprotov6.Diagnostic{ diff --git a/tf6to5server/tf6to5server.go b/tf6to5server/tf6to5server.go index 275216f..293daaf 100644 --- a/tf6to5server/tf6to5server.go +++ b/tf6to5server/tf6to5server.go @@ -295,8 +295,7 @@ func (s v6tov5Server) ValidateResourceTypeConfig(ctx context.Context, req *tfpro func (s v6tov5Server) ValidateListResourceConfig(ctx context.Context, req *tfprotov5.ValidateListResourceConfigRequest) (*tfprotov5.ValidateListResourceConfigResponse, error) { // TODO: Remove and call s.v6Server.ValidateListResourceConfig below directly once interface becomes required - //nolint:staticcheck // Intentionally verifying interface implementation - listResourceServer, ok := s.v6Server.(tfprotov6.ProviderServerWithListResource) + listResourceServer, ok := s.v6Server.(tfprotov6.ListResourceServer) if !ok { v5Resp := &tfprotov5.ValidateListResourceConfigResponse{ Diagnostics: []*tfprotov5.Diagnostic{ @@ -324,8 +323,7 @@ func (s v6tov5Server) ValidateListResourceConfig(ctx context.Context, req *tfpro func (s v6tov5Server) ListResource(ctx context.Context, req *tfprotov5.ListResourceRequest) (*tfprotov5.ListResourceServerStream, error) { // TODO: Remove and call s.v6Server.ListResource below directly once interface becomes required - //nolint:staticcheck // Intentionally verifying interface implementation - listResourceServer, ok := s.v6Server.(tfprotov6.ProviderServerWithListResource) + listResourceServer, ok := s.v6Server.(tfprotov6.ListResourceServer) if !ok { v5Resp := &tfprotov5.ListResourceServerStream{ Results: slices.Values([]tfprotov5.ListResourceResult{ @@ -354,3 +352,67 @@ func (s v6tov5Server) ListResource(ctx context.Context, req *tfprotov5.ListResou return tfprotov6tov5.ListResourceServerStream(v6Resp), nil } + +func (s v6tov5Server) PlanAction(ctx context.Context, req *tfprotov5.PlanActionRequest) (*tfprotov5.PlanActionResponse, error) { + // TODO: Remove and call s.v6Server.PlanAction below directly once interface becomes required + actionServer, ok := s.v6Server.(tfprotov6.ActionServer) + if !ok { + v5Resp := &tfprotov5.PlanActionResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "PlanAction Not Implemented", + Detail: "A PlanAction call was received by the provider, however the provider does not implement the RPC. " + + "Either upgrade the provider to a version that implements PlanAction or this is a bug in Terraform that should be reported to the Terraform maintainers.", + }, + }, + } + + return v5Resp, nil + } + + v6Req := tfprotov5tov6.PlanActionRequest(req) + + // v6Resp, err := s.v6Server.PlanAction(ctx, v6Req) + v6Resp, err := actionServer.PlanAction(ctx, v6Req) + if err != nil { + return nil, err + } + + return tfprotov6tov5.PlanActionResponse(v6Resp), nil +} + +func (s v6tov5Server) InvokeAction(ctx context.Context, req *tfprotov5.InvokeActionRequest) (*tfprotov5.InvokeActionServerStream, error) { + // TODO: Remove and call s.v6Server.InvokeAction below directly once interface becomes required + actionServer, ok := s.v6Server.(tfprotov6.ActionServer) + if !ok { + v5Resp := &tfprotov5.InvokeActionServerStream{ + Events: slices.Values([]tfprotov5.InvokeActionEvent{ + { + Type: tfprotov5.CompletedInvokeActionEventType{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "InvokeAction Not Implemented", + Detail: "An InvokeAction call was received by the provider, however the provider does not implement the RPC. " + + "Either upgrade the provider to a version that implements InvokeAction or this is a bug in Terraform that should be reported to the Terraform maintainers.", + }, + }, + }, + }, + }), + } + + return v5Resp, nil + } + + v6Req := tfprotov5tov6.InvokeActionRequest(req) + + // v6Resp, err := s.v6Server.InvokeAction(ctx, v6Req) + v6Resp, err := actionServer.InvokeAction(ctx, v6Req) + if err != nil { + return nil, err + } + + return tfprotov6tov5.InvokeActionServerStream(v6Resp), nil +} diff --git a/tf6to5server/tf6to5server_test.go b/tf6to5server/tf6to5server_test.go index 253c440..e709656 100644 --- a/tf6to5server/tf6to5server_test.go +++ b/tf6to5server/tf6to5server_test.go @@ -28,6 +28,11 @@ func TestDowngradeServer(t *testing.T) { "compatible": { v6Server: (&tf6testserver.TestServer{ GetProviderSchemaResponse: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_action": { + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + }, DataSourceSchemas: map[string]*tfprotov6.Schema{ "test_data_source": {}, }, @@ -37,6 +42,9 @@ func TestDowngradeServer(t *testing.T) { Functions: map[string]*tfprotov6.Function{ "test_function": {}, }, + ListResourceSchemas: map[string]*tfprotov6.Schema{ + "test_list_resource": {}, + }, Provider: &tfprotov6.Schema{ Block: &tfprotov6.SchemaBlock{ Attributes: []*tfprotov6.SchemaAttribute{ @@ -351,7 +359,7 @@ func TestV6ToV5ServerConfigureProvider(t *testing.T) { } } -func TestV5ToV6ServerGetFunctions(t *testing.T) { +func TestV6ToV5ServerGetFunctions(t *testing.T) { t.Parallel() ctx := context.Background() @@ -380,7 +388,7 @@ func TestV5ToV6ServerGetFunctions(t *testing.T) { } } -func TestV5ToV6ServerGetMetadata(t *testing.T) { +func TestV6ToV5ServerGetMetadata(t *testing.T) { t.Parallel() ctx := context.Background() @@ -968,3 +976,81 @@ func TestV6ToV5ServerListResource(t *testing.T) { t.Errorf("expected test_list_resource ListResource to be called") } } + +func TestV6ToV5ServerPlanAction(t *testing.T) { + t.Parallel() + + ctx := context.Background() + v6server := &tf6testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_action": { + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + }, + }, + } + + v5server, err := tf6to5server.DowngradeServer(context.Background(), v6server.ProviderServer) + + if err != nil { + t.Fatalf("unexpected error downgrading server: %s", err) + } + + //nolint:staticcheck // Intentionally verifying interface implementation + actionServer, ok := v5server.(tfprotov5.ProviderServerWithActions) + if !ok { + t.Fatal("v5server should implement tfprotov5.ProviderServerWithActions") + } + + _, err = actionServer.PlanAction(ctx, &tfprotov5.PlanActionRequest{ + ActionType: "test_action", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !v6server.PlanActionCalled["test_action"] { + t.Errorf("expected test_action PlanAction to be called") + } +} + +func TestV6ToV5ServerInvokeAction(t *testing.T) { + t.Parallel() + + ctx := context.Background() + v6server := &tf6testserver.TestServer{ + GetProviderSchemaResponse: &tfprotov6.GetProviderSchemaResponse{ + ActionSchemas: map[string]*tfprotov6.ActionSchema{ + "test_action": { + Type: tfprotov6.UnlinkedActionSchemaType{}, + }, + }, + }, + } + + v5server, err := tf6to5server.DowngradeServer(context.Background(), v6server.ProviderServer) + + if err != nil { + t.Fatalf("unexpected error upgrading server: %s", err) + } + + //nolint:staticcheck // Intentionally verifying interface implementation + actionServer, ok := v5server.(tfprotov5.ProviderServerWithActions) + if !ok { + t.Fatal("v5server should implement tfprotov5.ProviderServerWithActions") + } + + _, err = actionServer.InvokeAction(ctx, &tfprotov5.InvokeActionRequest{ + ActionType: "test_action", + }) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !v6server.InvokeActionCalled["test_action"] { + t.Errorf("expected test_action InvokeAction to be called") + } +}