diff --git a/plugin/external/sdk/iacserver.go b/plugin/external/sdk/iacserver.go new file mode 100644 index 00000000..f502258d --- /dev/null +++ b/plugin/external/sdk/iacserver.go @@ -0,0 +1,96 @@ +package sdk + +import ( + "fmt" + "reflect" + + "google.golang.org/grpc" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// RegisterAllIaCProviderServices uses Go type-assertion to register every +// typed IaC gRPC service that the provider satisfies, in a single call. +// +// REQUIRED service: +// +// pb.IaCProviderRequiredServer — every IaC plugin MUST implement every +// method on this interface. The type-assert here surfaces missing +// methods at plugin-startup time as a clear error rather than at the +// first RPC dispatch with a generic "unimplemented" status. +// +// OPTIONAL services (auto-detected): +// +// pb.IaCProviderEnumeratorServer +// pb.IaCProviderDriftDetectorServer +// pb.IaCProviderCredentialRevokerServer +// pb.IaCProviderMigrationRepairerServer +// pb.IaCProviderValidatorServer +// pb.IaCProviderDriftConfigDetectorServer +// +// ResourceDriver: +// +// pb.ResourceDriverServer — separate gRPC service, also auto-registered +// when the provider satisfies it. +// +// Per cycle 3 I-1 of the strict-contracts force-cutover design: plugin +// authors write ONE call; they cannot omit registration for a capability +// they implemented. That eliminates the registration-omission bug class +// (the same shape as the legacy InvokeService case-string-typo bug) by +// removing the manual step entirely. +// +// Capability discovery on the host side uses the existing ContractRegistry +// RPC + FileDescriptorSet mechanism (kept via §Salvage in the design); +// the SDK auto-publishes the registered services there in Task 5. +// +// Plugin authors who DO NOT want a capability advertised must NOT +// implement those methods at the Go level — there is no half-implemented +// stub-and-forget-to-register failure mode. +func RegisterAllIaCProviderServices(s *grpc.Server, provider any) error { + if s == nil { + return fmt.Errorf("RegisterAllIaCProviderServices: grpc server is nil") + } + if provider == nil { + return fmt.Errorf("RegisterAllIaCProviderServices: provider is nil") + } + // Typed-nil hardening: a typed-nil pointer (e.g., var p *MyProvider; + // RegisterAll(s, p)) wraps as a non-nil interface value but + // dereferences to nil at first method call → panic. Reject early + // with the same pattern the user-visible nil-check uses. + if rv := reflect.ValueOf(provider); rv.Kind() == reflect.Pointer && rv.IsNil() { + return fmt.Errorf("RegisterAllIaCProviderServices: provider is a typed-nil %T pointer", provider) + } + required, ok := provider.(pb.IaCProviderRequiredServer) + if !ok { + return fmt.Errorf( + "RegisterAllIaCProviderServices: provider %T does not satisfy "+ + "pb.IaCProviderRequiredServer (missing methods); see "+ + "docs/plans/2026-05-10-strict-contracts-force-cutover-design.md", + provider, + ) + } + pb.RegisterIaCProviderRequiredServer(s, required) + + if v, ok := provider.(pb.IaCProviderEnumeratorServer); ok { + pb.RegisterIaCProviderEnumeratorServer(s, v) + } + if v, ok := provider.(pb.IaCProviderDriftDetectorServer); ok { + pb.RegisterIaCProviderDriftDetectorServer(s, v) + } + if v, ok := provider.(pb.IaCProviderCredentialRevokerServer); ok { + pb.RegisterIaCProviderCredentialRevokerServer(s, v) + } + if v, ok := provider.(pb.IaCProviderMigrationRepairerServer); ok { + pb.RegisterIaCProviderMigrationRepairerServer(s, v) + } + if v, ok := provider.(pb.IaCProviderValidatorServer); ok { + pb.RegisterIaCProviderValidatorServer(s, v) + } + if v, ok := provider.(pb.IaCProviderDriftConfigDetectorServer); ok { + pb.RegisterIaCProviderDriftConfigDetectorServer(s, v) + } + if v, ok := provider.(pb.ResourceDriverServer); ok { + pb.RegisterResourceDriverServer(s, v) + } + return nil +} diff --git a/plugin/external/sdk/iacserver_test.go b/plugin/external/sdk/iacserver_test.go new file mode 100644 index 00000000..90f12abb --- /dev/null +++ b/plugin/external/sdk/iacserver_test.go @@ -0,0 +1,149 @@ +package sdk_test + +import ( + "strings" + "testing" + + "google.golang.org/grpc" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// TestRegisterAllIaCProviderServices_RequiredSatisfied_RegistersRequired +// asserts that a provider satisfying IaCProviderRequiredServer succeeds and +// the gRPC server actually advertises the typed service. +func TestRegisterAllIaCProviderServices_RequiredSatisfied_RegistersRequired(t *testing.T) { + grpcSrv := grpc.NewServer() + provider := &fullProviderStub{} + if err := sdk.RegisterAllIaCProviderServices(grpcSrv, provider); err != nil { + t.Fatalf("unexpected error: %v", err) + } + info := grpcSrv.GetServiceInfo() + if _, ok := info["workflow.plugin.external.iac.IaCProviderRequired"]; !ok { + t.Fatalf("required service not registered; have services: %v", serviceNames(info)) + } +} + +// TestRegisterAllIaCProviderServices_OptionalSatisfied_RegistersOptional +// asserts auto-detection: a provider that satisfies the Enumerator interface +// (and only that optional) gets the Enumerator service registered, but other +// optional services stay absent. +func TestRegisterAllIaCProviderServices_OptionalSatisfied_RegistersOptional(t *testing.T) { + grpcSrv := grpc.NewServer() + provider := &enumeratorOnlyStub{} + if err := sdk.RegisterAllIaCProviderServices(grpcSrv, provider); err != nil { + t.Fatalf("unexpected error: %v", err) + } + info := grpcSrv.GetServiceInfo() + if _, ok := info["workflow.plugin.external.iac.IaCProviderEnumerator"]; !ok { + t.Fatalf("Enumerator optional service NOT registered despite provider satisfying interface; have: %v", serviceNames(info)) + } + if _, ok := info["workflow.plugin.external.iac.IaCProviderDriftDetector"]; ok { + t.Fatalf("DriftDetector incorrectly registered (provider doesn't satisfy)") + } +} + +// TestRegisterAllIaCProviderServices_RequiredMissing_ReturnsError +// asserts that an empty provider produces an actionable error naming the +// unsatisfied required interface — the bug-class prevention pivot. +func TestRegisterAllIaCProviderServices_RequiredMissing_ReturnsError(t *testing.T) { + grpcSrv := grpc.NewServer() + provider := &emptyStub{} // doesn't satisfy IaCProviderRequiredServer + err := sdk.RegisterAllIaCProviderServices(grpcSrv, provider) + if err == nil { + t.Fatalf("expected error for unsatisfied required interface; got nil") + } + if !strings.Contains(err.Error(), "IaCProviderRequiredServer") { + t.Fatalf("error message must name the unsatisfied interface; got %q", err.Error()) + } +} + +// TestRegisterAllIaCProviderServices_TypedNilPointer_ReturnsError +// asserts the typed-nil-pointer hardening: a (*T)(nil) wrapped in an +// `any` interface is non-nil at the interface layer (interface header +// has a type), but dereferences to nil at first method call. Previous +// `provider == nil` check missed it; reflect-based check catches it +// and rejects with a typed error before any registration happens. +// +// Per cycle 4 code-review PR 611 typed-nil hardening (Copilot finding). +func TestRegisterAllIaCProviderServices_TypedNilPointer_ReturnsError(t *testing.T) { + grpcSrv := grpc.NewServer() + var provider *fullProviderStub // typed-nil pointer + err := sdk.RegisterAllIaCProviderServices(grpcSrv, provider) + if err == nil { + t.Fatalf("expected error for typed-nil pointer; got nil") + } + if !strings.Contains(err.Error(), "typed-nil") { + t.Errorf("error must name typed-nil; got %q", err.Error()) + } + if got := len(grpcSrv.GetServiceInfo()); got != 0 { + t.Errorf("no services should be registered on rejection; got %d", got) + } +} + +// TestRegisterAllIaCProviderServices_AllOptionals_AllRegistered +// asserts that a provider satisfying every optional + required interface +// triggers registration of all 7 typed services (Required + 6 optional) +// plus the ResourceDriver. +func TestRegisterAllIaCProviderServices_AllOptionals_AllRegistered(t *testing.T) { + grpcSrv := grpc.NewServer() + provider := &allCapabilitiesStub{} + if err := sdk.RegisterAllIaCProviderServices(grpcSrv, provider); err != nil { + t.Fatalf("unexpected error: %v", err) + } + info := grpcSrv.GetServiceInfo() + wantServices := []string{ + "workflow.plugin.external.iac.IaCProviderRequired", + "workflow.plugin.external.iac.IaCProviderEnumerator", + "workflow.plugin.external.iac.IaCProviderDriftDetector", + "workflow.plugin.external.iac.IaCProviderCredentialRevoker", + "workflow.plugin.external.iac.IaCProviderMigrationRepairer", + "workflow.plugin.external.iac.IaCProviderValidator", + "workflow.plugin.external.iac.IaCProviderDriftConfigDetector", + "workflow.plugin.external.iac.ResourceDriver", + } + for _, name := range wantServices { + if _, ok := info[name]; !ok { + t.Errorf("expected service %q registered; have: %v", name, serviceNames(info)) + } + } +} + +func serviceNames(info map[string]grpc.ServiceInfo) []string { + out := make([]string, 0, len(info)) + for k := range info { + out = append(out, k) + } + return out +} + +// fullProviderStub satisfies IaCProviderRequired + Enumerator + DriftDetector +// (representative of an early-stage DO plugin shape). +type fullProviderStub struct { + pb.UnimplementedIaCProviderRequiredServer + pb.UnimplementedIaCProviderEnumeratorServer + pb.UnimplementedIaCProviderDriftDetectorServer +} + +// enumeratorOnlyStub satisfies Required + Enumerator only. +type enumeratorOnlyStub struct { + pb.UnimplementedIaCProviderRequiredServer + pb.UnimplementedIaCProviderEnumeratorServer +} + +// allCapabilitiesStub satisfies every required + optional IaC service plus +// ResourceDriver — used to assert auto-registration covers the full surface. +type allCapabilitiesStub struct { + pb.UnimplementedIaCProviderRequiredServer + pb.UnimplementedIaCProviderEnumeratorServer + pb.UnimplementedIaCProviderDriftDetectorServer + pb.UnimplementedIaCProviderCredentialRevokerServer + pb.UnimplementedIaCProviderMigrationRepairerServer + pb.UnimplementedIaCProviderValidatorServer + pb.UnimplementedIaCProviderDriftConfigDetectorServer + pb.UnimplementedResourceDriverServer +} + +// emptyStub satisfies no IaC interface; the helper must reject it. +type emptyStub struct{}