Skip to content
Merged
37 changes: 37 additions & 0 deletions .github/workflows/cross-plugin-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ on:
- 'iac/**'
- 'platform/**'
- 'plugin/sdk/**'
# Strict-contracts cutover Task 6 — typed IaC contract + adapters live
# under plugin/external/{proto,sdk}; downstream IaC dispatch + remote-
# plugin orchestration code lives elsewhere under plugin/external/.
# Both surfaces affect the iac-typed-e2e job (cross-plugin gRPC wire
# behavior + handshake/loader changes); use the broad gate on
# plugin/external/** so any structural change to the external-plugin
# boundary triggers the cross-plugin smoke. CI cost is bounded —
# changes outside this dir already skip via the rest of the filter.
- 'plugin/external/**'
- 'go.mod'
- 'go.sum'
- '.github/workflows/cross-plugin-build-test.yml'
Expand Down Expand Up @@ -61,3 +70,31 @@ jobs:
go mod tidy
go build ./...
# NOTE: not `go test` — those plugins have their own CI; we just verify compile-compat

# Strict-contracts force-cutover Task 6: typed-IaC E2E integration test.
#
# The cross-plugin-build job above only verifies `go build` against AWS/GCP/
# Azure plugins. For the typed IaC contract introduced by the strict-
# contracts cutover (PR 2 of the plan), `go build` is necessary but not
# sufficient — wire incompat between workflow and plugin grpc-go versions
# would slip through (per cycle 1 I-2 + cycle 2 I-1-NEW).
#
# This job runs the workflow-side in-process E2E test built with
# -tags=integration. The same test exercises the typed gRPC services
# registered via sdk.RegisterAllIaCProviderServices through a real bufconn
# gRPC channel, asserting the typed roundtrip preserves shape (including
# the map<string,bool> sensitive field that the legacy structpb path
# silently dropped).
#
# The subprocess wire-test variant against the real DO plugin v1.0.0 binary
# is added once that plugin ships — see plan §PR 3 (Task 7+).
iac-typed-e2e:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version-file: go.mod }
- name: Typed-IaC E2E test (in-process gRPC roundtrip)
run: GOWORK=off go test -tags=integration ./plugin/external/sdk/... -run TestIaC_EndToEnd -count=1 -v
60 changes: 60 additions & 0 deletions plugin/external/sdk/contracts.go
Original file line number Diff line number Diff line change
@@ -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
}
95 changes: 95 additions & 0 deletions plugin/external/sdk/contracts_iac_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading