From 8bb6e527fd645bfc526795b3ba7589b8628477da Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 10 May 2026 02:24:03 -0400 Subject: [PATCH] feat(sdk): BuildContractRegistry advertises registered IaC services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5 of the strict-contracts force-cutover plan (docs/plans/2026-05-10-strict-contracts-force-cutover.md, rev5). Adds plugin/external/sdk/contracts.go with the BuildContractRegistry helper that enumerates grpc.Server.GetServiceInfo() and emits a SERVICE-kind ContractDescriptor for each registered service. ContractMode is set to STRICT_PROTO so the host can distinguish typed IaC services from the legacy structpb-mode contracts produced by Module/Step/Trigger ContractProvider implementations. Per cycle 3 I-1 of the design: wfctl needs a single mechanism to discover "is the optional service registered on this plugin handle?". Reusing the existing ContractRegistry shape keeps Module/Step/Trigger and IaC capability discovery on the same wire surface — no new gRPC server-reflection dependency required. Service descriptors are emitted in deterministic alphabetical order so callers can rely on stable output for diff/compare operations and the wftest BDD test in Task 15. The helper is safe to call with a nil server (returns an empty but non-nil ContractRegistry) so callers that may construct it before the gRPC server exists do not panic. Tests (contracts_iac_test.go) cover three cases — all pass: - AdvertisesRegisteredIaCServices: a Required + Enumerator + DriftDetector stub yields exactly those service descriptors. - ServiceContractsUseStrictProtoMode: every emitted descriptor is Kind=SERVICE + Mode=STRICT_PROTO (host-side discriminator). - NilServer_ReturnsEmpty: defensive contract for nil input. Stacked on feat/iac-sdk-serve-task29 (Task 29 PR #600 provides ServeIaCPlugin which IaC plugins use to register the services this helper enumerates). Verification: GOWORK=off go test -race ./plugin/external/sdk/... PASS; GOWORK=off go build ./plugin/... ./cmd/... ./module/... clean; GOWORK=off go vet ./plugin/external/... clean. Rollback: revert this commit; ContractRegistry returns the prior shape (Module/Step/Trigger only via the existing ContractProvider hook in grpc_server.go). --- plugin/external/sdk/contracts.go | 60 ++++++++++++++ plugin/external/sdk/contracts_iac_test.go | 95 +++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 plugin/external/sdk/contracts.go create mode 100644 plugin/external/sdk/contracts_iac_test.go diff --git a/plugin/external/sdk/contracts.go b/plugin/external/sdk/contracts.go new file mode 100644 index 000000000..b9f28989a --- /dev/null +++ b/plugin/external/sdk/contracts.go @@ -0,0 +1,60 @@ +package sdk + +import ( + "sort" + + "google.golang.org/grpc" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// BuildContractRegistry enumerates the gRPC services registered on +// grpcSrv and returns a *pb.ContractRegistry with a SERVICE-kind +// ContractDescriptor for each one. Mode is set to +// CONTRACT_MODE_STRICT_PROTO so the host can distinguish typed IaC +// services from the legacy structpb-mode contracts produced by +// Module/Step/Trigger ContractProvider implementations. +// +// Why this exists (per cycle 3 I-1 of the strict-contracts force-cutover +// design): wfctl needs a single mechanism to discover "is the optional +// service registered on this plugin handle?". Reusing the existing +// ContractRegistry shape keeps Module/Step/Trigger and IaC capability +// discovery on the same wire surface — no new server-reflection +// dependency required. +// +// The helper is safe to call with a nil server; it returns an empty +// (but non-nil) ContractRegistry. Service descriptors are emitted in a +// deterministic alphabetical order so callers can rely on stable +// FileDescriptorSet-adjacent output for diff/compare operations and +// the wftest BDD test in Task 15. +// +// IaC plugin authors typically wire this into their ContractProvider +// implementation: +// +// func (p *plugin) ContractRegistry() *pb.ContractRegistry { +// return sdk.BuildContractRegistry(p.grpcServer) +// } +// +// where p.grpcServer was captured inside the iacGRPCPlugin.GRPCServer +// callback at startup. The ContractProvider hook keeps the wfctl-side +// GetContractRegistry RPC path unchanged. +func BuildContractRegistry(grpcSrv *grpc.Server) *pb.ContractRegistry { + registry := &pb.ContractRegistry{} + if grpcSrv == nil { + return registry + } + info := grpcSrv.GetServiceInfo() + names := make([]string, 0, len(info)) + for name := range info { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + registry.Contracts = append(registry.Contracts, &pb.ContractDescriptor{ + Kind: pb.ContractKind_CONTRACT_KIND_SERVICE, + ServiceName: name, + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + }) + } + return registry +} diff --git a/plugin/external/sdk/contracts_iac_test.go b/plugin/external/sdk/contracts_iac_test.go new file mode 100644 index 000000000..b67aebaf8 --- /dev/null +++ b/plugin/external/sdk/contracts_iac_test.go @@ -0,0 +1,95 @@ +package sdk_test + +import ( + "testing" + + "google.golang.org/grpc" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// TestBuildContractRegistry_AdvertisesRegisteredIaCServices asserts that +// after calling RegisterAllIaCProviderServices, BuildContractRegistry +// returns a *pb.ContractRegistry that lists the registered IaC services +// as SERVICE-kind ContractDescriptors. wfctl uses this for capability +// discovery against IaC plugins (per design §Optional services — single +// mechanism, no new server-reflection dependency). +func TestBuildContractRegistry_AdvertisesRegisteredIaCServices(t *testing.T) { + grpcSrv := grpc.NewServer() + provider := &iacContractProviderStub{} + if err := sdk.RegisterAllIaCProviderServices(grpcSrv, provider); err != nil { + t.Fatalf("register: %v", err) + } + + registry := sdk.BuildContractRegistry(grpcSrv) + if registry == nil { + t.Fatalf("expected non-nil ContractRegistry") + } + + services := serviceNamesFromRegistry(registry) + want := []string{ + "workflow.plugin.external.iac.IaCProviderRequired", + "workflow.plugin.external.iac.IaCProviderEnumerator", + "workflow.plugin.external.iac.IaCProviderDriftDetector", + } + for _, name := range want { + if !services[name] { + t.Errorf("ContractRegistry missing service %q; have: %v", name, services) + } + } +} + +// TestBuildContractRegistry_ServiceContractsUseStrictProtoMode asserts +// that auto-emitted IaC service descriptors carry Mode=STRICT_PROTO so +// the host can distinguish them from the legacy structpb-mode contracts +// produced by Module/Step/Trigger ContractProvider implementations. +func TestBuildContractRegistry_ServiceContractsUseStrictProtoMode(t *testing.T) { + grpcSrv := grpc.NewServer() + if err := sdk.RegisterAllIaCProviderServices(grpcSrv, &iacContractProviderStub{}); err != nil { + t.Fatalf("register: %v", err) + } + registry := sdk.BuildContractRegistry(grpcSrv) + + for _, c := range registry.Contracts { + if c.Kind != pb.ContractKind_CONTRACT_KIND_SERVICE { + t.Errorf("unexpected non-service contract kind %v for %q", c.Kind, c.ServiceName) + continue + } + if c.Mode != pb.ContractMode_CONTRACT_MODE_STRICT_PROTO { + t.Errorf("service %q should be STRICT_PROTO mode; got %v", c.ServiceName, c.Mode) + } + } +} + +// TestBuildContractRegistry_NilServer_ReturnsEmpty asserts the helper is +// safe to call with a nil server (returns an empty registry rather than +// panicking) — defensive contract for callers that may construct the +// helper before the gRPC server exists. +func TestBuildContractRegistry_NilServer_ReturnsEmpty(t *testing.T) { + registry := sdk.BuildContractRegistry(nil) + if registry == nil { + t.Fatalf("expected non-nil empty ContractRegistry") + } + if len(registry.Contracts) != 0 { + t.Fatalf("expected empty contracts; got %d", len(registry.Contracts)) + } +} + +func serviceNamesFromRegistry(r *pb.ContractRegistry) map[string]bool { + out := make(map[string]bool, len(r.Contracts)) + for _, c := range r.Contracts { + if c.Kind == pb.ContractKind_CONTRACT_KIND_SERVICE { + out[c.ServiceName] = true + } + } + return out +} + +// iacContractProviderStub satisfies Required + Enumerator + DriftDetector +// to exercise the multi-service registration path. +type iacContractProviderStub struct { + pb.UnimplementedIaCProviderRequiredServer + pb.UnimplementedIaCProviderEnumeratorServer + pb.UnimplementedIaCProviderDriftDetectorServer +}