diff --git a/cmd/wfctl/iac_loader_gate.go b/cmd/wfctl/iac_loader_gate.go new file mode 100644 index 00000000..79380eee --- /dev/null +++ b/cmd/wfctl/iac_loader_gate.go @@ -0,0 +1,89 @@ +package main + +import ( + "errors" + "fmt" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// iacServiceRequired declared in iac_typed_adapter.go (PR #605, merged on +// main). This file uses the canonical const directly — the pre-flight gate +// looks for exactly that string in the plugin's GetContractRegistry response. + +// errLegacyIaCPlugin is the typed sentinel returned when a pinned IaC +// plugin does not advertise IaCProviderRequired in its +// ContractRegistry. Wrapped errors that errors.Is on this sentinel +// surface the install-time mitigation step. +// +// The dispatch sites (wfctl deploy, wfctl infra plan/apply) catch this +// and exit with the actionable message documented in +// docs/runbooks/iac-typed-cutover.md. +var errLegacyIaCPlugin = errors.New("iac: plugin uses legacy InvokeService dispatch removed in workflow v1.0.0") + +// AssertIaCPluginAdvertisesRequiredService inspects a *pb.ContractRegistry +// (the response from GetContractRegistry) and returns nil iff the plugin +// advertises a CONTRACT_KIND_SERVICE descriptor for +// workflow.plugin.external.iac.IaCProviderRequired. +// +// The error names the offending plugin (pluginName + pluginVersion) and +// points operators at docs/runbooks/iac-typed-cutover.md, plus wraps +// errLegacyIaCPlugin for errors.Is dispatch. +// +// Per Task 18 of the strict-contracts force-cutover plan: workflow +// v1.0.0 refuses to start a deploy if any pinned IaC plugin doesn't +// expose pb.IaCProviderServer registration. The check happens at the +// plugin-loader boundary (after GetContractRegistry succeeds) so the +// failure surfaces with a typed mitigation rather than as a generic +// "method not found" gRPC status at the first IaC RPC. +// +// pluginName + pluginVersion are forwarded to the error message; pass +// the values from the plugin's manifest (Manifest.Name + Version). +// They are advisory — the function still rejects a registry that +// lacks the typed service even when called with empty strings. +func AssertIaCPluginAdvertisesRequiredService(pluginName, pluginVersion string, registry *pb.ContractRegistry) error { + if !registryAdvertisesIaCRequired(registry) { + name := pluginName + if name == "" { + name = "" + } + version := pluginVersion + if version == "" { + version = "" + } + return fmt.Errorf( + "plugin %q v%s uses legacy InvokeService dispatch removed in workflow v1.0.0. "+ + "Migration: edit .wfctl-lock.yaml to pin v1.0.0+, then re-run "+ + "`wfctl plugin install`. See docs/runbooks/iac-typed-cutover.md: %w", + name, version, errLegacyIaCPlugin, + ) + } + return nil +} + +// registryAdvertisesIaCRequired returns true iff registry contains a +// CONTRACT_KIND_SERVICE descriptor naming +// workflow.plugin.external.iac.IaCProviderRequired. Treats nil +// registry / nil contracts slice as "not advertised." +func registryAdvertisesIaCRequired(registry *pb.ContractRegistry) bool { + if registry == nil { + return false + } + for _, c := range registry.Contracts { + if c == nil { + continue + } + if c.Kind == pb.ContractKind_CONTRACT_KIND_SERVICE && c.ServiceName == iacServiceRequired { + return true + } + } + return false +} + +// IsLegacyIaCPluginErr reports whether err signals a failed IaC +// plugin pre-flight gate. Dispatch sites can use this to attach +// runbook-specific exit codes / messages without inspecting the +// error message string. +func IsLegacyIaCPluginErr(err error) bool { + return errors.Is(err, errLegacyIaCPlugin) +} diff --git a/cmd/wfctl/iac_loader_gate_test.go b/cmd/wfctl/iac_loader_gate_test.go new file mode 100644 index 00000000..994db547 --- /dev/null +++ b/cmd/wfctl/iac_loader_gate_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "errors" + "strings" + "testing" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +// TestAssertIaCPluginAdvertisesRequiredService_TypedRegistryAccepts asserts +// that a ContractRegistry containing a SERVICE-kind descriptor for +// IaCProviderRequired passes the gate silently. Mirrors the +// post-cutover happy path: DO plugin v1.0.0 registers +// IaCProviderRequired via sdk.RegisterAllIaCProviderServices, then +// GetContractRegistry returns the typed-service descriptor that this +// gate looks for. +func TestAssertIaCPluginAdvertisesRequiredService_TypedRegistryAccepts(t *testing.T) { + registry := &pb.ContractRegistry{ + Contracts: []*pb.ContractDescriptor{ + { + Kind: pb.ContractKind_CONTRACT_KIND_SERVICE, + ServiceName: iacServiceRequired, + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + }, + }, + } + if err := AssertIaCPluginAdvertisesRequiredService("workflow-plugin-digitalocean", "v1.0.0", registry); err != nil { + t.Fatalf("expected nil for typed registry; got %v", err) + } +} + +// TestAssertIaCPluginAdvertisesRequiredService_LegacyRegistryRejects asserts +// the gate fires for a legacy plugin whose ContractRegistry advertises +// only Module/Step/Trigger contracts (no IaCProviderRequired service). +// The error MUST: name the plugin + version, include the migration +// instructions, point at the runbook, and wrap errLegacyIaCPlugin. +func TestAssertIaCPluginAdvertisesRequiredService_LegacyRegistryRejects(t *testing.T) { + registry := &pb.ContractRegistry{ + Contracts: []*pb.ContractDescriptor{ + { + Kind: pb.ContractKind_CONTRACT_KIND_MODULE, + ModuleType: "do.spaces_bucket", + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + }, + }, + } + err := AssertIaCPluginAdvertisesRequiredService("workflow-plugin-digitalocean", "v0.14.2", registry) + if err == nil { + t.Fatalf("expected legacy-plugin error; got nil") + } + if !IsLegacyIaCPluginErr(err) { + t.Fatalf("error must wrap errLegacyIaCPlugin; got %v", err) + } + for _, want := range []string{ + "workflow-plugin-digitalocean", + "v0.14.2", + ".wfctl-lock.yaml", + "docs/runbooks/iac-typed-cutover.md", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q; got %q", want, err.Error()) + } + } +} + +// TestAssertIaCPluginAdvertisesRequiredService_NilRegistryRejects asserts +// the gate treats a nil registry as legacy (defensive — a plugin that +// fails to return a registry at all is also pre-cutover). +func TestAssertIaCPluginAdvertisesRequiredService_NilRegistryRejects(t *testing.T) { + err := AssertIaCPluginAdvertisesRequiredService("plugin-x", "v0.0.1", nil) + if err == nil { + t.Fatalf("expected error for nil registry") + } + if !IsLegacyIaCPluginErr(err) { + t.Errorf("nil-registry error must wrap errLegacyIaCPlugin") + } +} + +// TestAssertIaCPluginAdvertisesRequiredService_EmptyContractsRejects +// asserts a registry with no contracts is treated as legacy. Catches +// the post-RPC path where GetContractRegistry returns successfully but +// empty (e.g., a plugin that built against typed proto but forgot to +// wire BuildContractRegistry into its ContractProvider hook). +func TestAssertIaCPluginAdvertisesRequiredService_EmptyContractsRejects(t *testing.T) { + registry := &pb.ContractRegistry{} + err := AssertIaCPluginAdvertisesRequiredService("plugin-y", "v1.0.0-rc0", registry) + if err == nil { + t.Fatalf("expected error for empty contracts slice") + } + if !IsLegacyIaCPluginErr(err) { + t.Errorf("empty-contracts error must wrap errLegacyIaCPlugin") + } +} + +// TestAssertIaCPluginAdvertisesRequiredService_WrongKindRejects asserts +// that a descriptor naming IaCProviderRequired but with the wrong +// CONTRACT_KIND (e.g., MODULE instead of SERVICE) does NOT satisfy the +// gate. Guards against a plugin author copy-pasting a service name +// into the wrong descriptor kind. +func TestAssertIaCPluginAdvertisesRequiredService_WrongKindRejects(t *testing.T) { + registry := &pb.ContractRegistry{ + Contracts: []*pb.ContractDescriptor{ + { + Kind: pb.ContractKind_CONTRACT_KIND_MODULE, + ServiceName: iacServiceRequired, // wrong kind + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + }, + }, + } + err := AssertIaCPluginAdvertisesRequiredService("plugin-z", "v0.5.0", registry) + if err == nil { + t.Fatalf("expected error: SERVICE-kind required (CONTRACT_KIND_MODULE seen)") + } +} + +// TestAssertIaCPluginAdvertisesRequiredService_EmptyMetadataDefaults +// asserts the error formats unknown plugin metadata gracefully when +// the loader didn't populate name/version (defensive — the gate +// should still surface a reasonable message). +func TestAssertIaCPluginAdvertisesRequiredService_EmptyMetadataDefaults(t *testing.T) { + err := AssertIaCPluginAdvertisesRequiredService("", "", nil) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "") { + t.Errorf("expected placeholder in error; got %q", err.Error()) + } +} + +// TestIsLegacyIaCPluginErr_NoFalsePositives asserts the sentinel does +// not match unrelated errors. Critical because dispatch sites use this +// to decide between "exit-with-runbook-message" and "exit with the +// generic plugin-load failure path." +func TestIsLegacyIaCPluginErr_NoFalsePositives(t *testing.T) { + if IsLegacyIaCPluginErr(nil) { + t.Errorf("nil should not match") + } + if IsLegacyIaCPluginErr(errors.New("some other error")) { + t.Errorf("unrelated error should not match") + } + if IsLegacyIaCPluginErr(errors.New("plugin uses legacy something")) { + t.Errorf("string-similar error should not match (we wrap a typed sentinel)") + } +} diff --git a/docs/runbooks/iac-typed-cutover.md b/docs/runbooks/iac-typed-cutover.md new file mode 100644 index 00000000..2021deb4 --- /dev/null +++ b/docs/runbooks/iac-typed-cutover.md @@ -0,0 +1,234 @@ +# IaC Typed-Contract Cutover Runbook + +This runbook walks operators through upgrading wfctl + IaC plugins +across the strict-contracts force-cutover (workflow `v1.0.0`, DO plugin +`v1.0.0`). + +The cutover replaces the legacy `InvokeService` / `*structpb.Struct` +dispatch path for `IaCProvider` and `ResourceDriver` interfaces with +typed gRPC services (`pb.IaCProviderRequiredServer`, plus 6 optional +typed services + `pb.ResourceDriverServer`). See ADRs +[0024](../../decisions/0024-iac-typed-force-cutover.md), +[0025](../../decisions/0025-iac-optional-method-typed-services-not-bool.md), +and [0026](../../decisions/0026-iac-direct-grpc-client-no-wrapper.md) +for the design decisions. + +This is a **hard cutover**. There is no plugin-side compat shim and no +build-tag dual-path (per ADR 0024 and `feedback_force_strict_contracts_no_compat`). +The wfctl rc1 window is **wfctl-side additive only**: rc1 ships a +typed-client adapter alongside the existing `remoteIaCProvider` so an +operator can install rc1 against their existing `v0.14.x` plugin set +and exercise the new path before forcing a plugin cutover. The plugin +side is typed-only at `v1.0.0`. + +## Compatibility matrix + +| wfctl version | DO plugin version | Outcome | +|------------------|-------------------|---------------------------------------------------------------------------| +| v0.27.x | v0.14.x | Legacy InvokeService dispatch (pre-cutover, supported) | +| **v1.0.0-rc1** | v0.14.x | wfctl-side legacy path (`remoteIaCProvider`) — supported transition state | +| v1.0.0-rc1 | v1.0.0 | Typed gRPC, advisory checks, cutover-ready | +| **v0.27.x** | **v1.0.0** | **BROKEN by design** — legacy wfctl cannot dispatch typed-only plugin | +| v1.0.0 final | v1.0.0 | Typed gRPC, strict; legacy paths deleted from wfctl | +| v1.0.0 final | v0.14.x | wfctl refuses with actionable error pointing here | + +The pre-flight gate (`cmd/wfctl/iac_loader_gate.go`) reads +`GetContractRegistry` from each loaded IaC plugin and refuses to +proceed when a plugin doesn't advertise +`workflow.plugin.external.iac.IaCProviderRequired`. The error message +names the offending plugin + version and points back to this runbook. + +## Upgrade order + +The order is **wfctl rc1 first → plugin v1.0.0 second → wfctl v1.0.0 +final third**. Operators MUST run wfctl rc1+ to consume DO `v1.0.0` +because the legacy wfctl binary cannot dispatch through a typed-only +plugin. + +### Step 1 — install wfctl `v1.0.0-rc1` + +The rc1 binary keeps the wfctl-SIDE legacy `remoteIaCProvider` path +alongside the new typed-client adapter. Existing `v0.14.x` plugins +continue to work unchanged; the new typed adapter is exercised only +when a typed-aware plugin is loaded. + +```sh +brew install wfctl@1.0.0-rc1 # or your preferred installer +wfctl --version # verify +wfctl infra plan -c infra.yaml -e prod # smoke-test against current plugin set +``` + +A successful `infra plan` against the existing `v0.14.x` plugin set +confirms rc1 doesn't regress the legacy path. + +### Step 2 — pin DO plugin `v1.0.0` in `.wfctl-lock.yaml` + +```yaml +# .wfctl-lock.yaml (edit by hand, then re-resolve) +plugins: + digitalocean: + version: "v1.0.0" + # The checksum will be regenerated by `wfctl plugin install`. + checksum: "" +``` + +Re-resolve: + +```sh +wfctl plugin install +``` + +This downloads the v1.0.0 plugin binary, verifies the checksum, and +writes the resolved digest back into `.wfctl-lock.yaml`. The DO `v1.0.0` +plugin deletes its legacy `internal/module_instance.go` switch +dispatcher entirely (per Task 9 of the cutover plan); only the typed +gRPC services remain. + +### Step 3 — verify the typed contract surface is advertised + +```sh +wfctl plugin contracts --plugin digitalocean +``` + +The output MUST include: + +``` +workflow.plugin.external.iac.IaCProviderRequired +``` + +If `IaCProviderRequired` is missing, the plugin you installed is not a +`v1.0.0` build (likely a stale cache). Clear `~/.wfctl/cache/plugins` +and re-run `wfctl plugin install`. + +### Step 4 — exercise the typed path under wfctl rc1 + +```sh +wfctl infra plan -c infra.yaml -e prod +wfctl infra apply -c infra.yaml -e prod --dry-run +``` + +A successful plan + dry-run apply confirms the typed adapter dispatches +the DO `v1.0.0` plugin correctly. If the pre-flight gate fires here, +the plugin install in Step 2 didn't pick up `v1.0.0` — re-check Step 3. + +### Step 5 — upgrade wfctl to `v1.0.0` final (deletes legacy path) + +```sh +brew upgrade wfctl # or your preferred installer; targets v1.0.0 +wfctl infra plan -c infra.yaml -e prod +``` + +Workflow `v1.0.0` final removes the wfctl-side legacy `remoteIaCProvider` +(Task 20 of the cutover plan). The typed adapter is the only IaC +dispatch path. A successful `infra plan` against the DO `v1.0.0` +plugin confirms the cutover landed. + +## `.wfctl-lock.yaml` migration + +The lockfile shape did NOT change in v1.0.0. The cutover is wire-format +only (gRPC envelope between wfctl and plugin); the on-disk lockfile + +state-file schemas are invariant. See PR #608's pre-flight test +(`cmd/wfctl/state_compat_test.go`) for the schema-stability guard. + +Existing lockfiles continue to work. The only required edit is the +plugin version pin (Step 2 above). + +## Troubleshooting + +### "plugin X uses legacy InvokeService dispatch removed in workflow v1.0.0" + +The pre-flight gate fired. The plugin pinned in `.wfctl-lock.yaml` is +older than the `v1.0.0` release. Re-run Step 2. + +If the lockfile already pins `v1.0.0+` but the error persists, the +plugin cache may have a stale binary: + +```sh +rm -rf ~/.wfctl/cache/plugins/digitalocean* +wfctl plugin install +``` + +### Operator skipped Step 1 (wfctl rc1) and went directly to plugin v1.0.0 + +The `v0.27.x × v1.0.0` matrix row is **broken by design**. The legacy +wfctl binary's `remoteIaCProvider` proxy issues `InvokeService` RPCs +against method names like `"IaCProvider.EnumerateAll"`; the typed-only +DO `v1.0.0` plugin returns `codes.Unimplemented` for every one of +those. Symptom: every IaC command fails with "method not found" or +"unimplemented" errors. + +Recover by: + +```sh +brew install wfctl@1.0.0-rc1 # back to a wfctl that has the typed adapter +wfctl infra plan -c infra.yaml -e prod # confirm the typed path works +``` + +### "no loaded provider implements EnumeratorAll" (or similar) + +Pre-cutover behavior. Indicates the legacy `*remoteIaCProvider` proxy +encountered a plugin that exposed only a subset of the optional +sub-interfaces. After the cutover, this message no longer appears — +each optional capability is advertised as a typed gRPC service in +`GetContractRegistry`. If you see this message after the upgrade, you +did NOT upgrade wfctl; verify with `wfctl --version`. + +### "decode v0.14.2 state failed: schema regression" + +The state-file format invariant has been violated. File a bug against +the workflow repo and include the affected state file (redact secrets). +Production state files written by v0.14.x MUST decode cleanly through +v1.0.0 — the pre-flight test +(`cmd/wfctl/state_compat_test.go::TestStateFileCompat_v0_14_2_to_v1_0_0`) +guards this. + +### Plugin advertises `IaCProviderRequired` but a deploy still fails with a typed-RPC error + +The plugin's typed implementation has a bug. Roll back to the previous +plugin version in `.wfctl-lock.yaml` (re-pin the older version, re-run +`wfctl plugin install`) and file a bug against the plugin repo. The +pre-flight gate only confirms the typed service is **advertised**; it +cannot validate that the implementation is correct. The +`AssertProviderCapabilitiesMatchRegistration` BDD helper +(`wftest/bdd/strict_iac.go`) is the development-time guard for that. + +## Backout plan + +The cutover is hard: there is no plugin-side compat shim. A regression +post-cutover requires rolling back BOTH the wfctl binary AND the DO +plugin to pre-cutover versions. Single-side rollback (only wfctl, only +plugin) is broken — the wfctl `v1.0.0` final binary cannot dispatch +through a `v0.14.x` plugin (legacy `remoteIaCProvider` was deleted in +Task 20), and the legacy `v0.27.x` wfctl cannot dispatch through a DO +`v1.0.0` plugin (typed-only). + +Steps: + +1. Re-pin DO plugin to the latest `v0.14.x` in `.wfctl-lock.yaml`. +2. Re-install wfctl `v0.27.x` via your distribution's install path + (`brew install wfctl@0.27` or equivalent). +3. Re-run `wfctl plugin install` to refresh the lockfile. +4. Verify rollback: + ```sh + wfctl --version # must show v0.27.x + wfctl infra plan -c infra.yaml -e prod + ``` +5. File a bug against the workflow repo with the failing `wfctl infra + plan` output from the cutover state. + +If rolled-back state holds for >24h, file the bug as `priority:high` +(operators are stuck on the pre-cutover line). + +The rc1 window provides a partial-rollback option: while wfctl is on +`v1.0.0-rc1`, falling back to `v0.14.x` plugin is supported (rc1 +retains the wfctl-side legacy adapter for exactly this purpose). After +wfctl `v1.0.0` final ships, the both-sides rollback is the only +supported recovery. + +## See also + +- [ADR 0024 — IaC typed force-cutover](../../decisions/0024-iac-typed-force-cutover.md) +- [ADR 0025 — Optional methods are typed gRPC services](../../decisions/0025-iac-optional-method-typed-services-not-bool.md) +- [ADR 0026 — wfctl uses pb.IaCProviderClient directly](../../decisions/0026-iac-direct-grpc-client-no-wrapper.md) +- [Plan: 2026-05-10-strict-contracts-force-cutover.md](../plans/2026-05-10-strict-contracts-force-cutover.md) +- [Design: 2026-05-10-strict-contracts-force-cutover-design.md](../plans/2026-05-10-strict-contracts-force-cutover-design.md) diff --git a/plugin/external/sdk/contracts.go b/plugin/external/sdk/contracts.go new file mode 100644 index 00000000..b9f28989 --- /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 00000000..b67aebaf --- /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 +}