Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions plugin/external/sdk/iacserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

goplugin "github.com/GoCodeAlone/go-plugin"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"

ext "github.com/GoCodeAlone/workflow/plugin/external"
pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
Expand Down Expand Up @@ -95,9 +96,46 @@ func RegisterAllIaCProviderServices(s *grpc.Server, provider any) error {
if v, ok := provider.(pb.ResourceDriverServer); ok {
pb.RegisterResourceDriverServer(s, v)
}

// Register a minimal PluginService so the wfctl host can call
// GetContractRegistry to discover the typed IaC services registered
// above. Strict-cutover IaC plugins (e.g. DO v1.0.0) that use
// ServeIaCPlugin do NOT register the SDK grpcServer (which normally
// handles GetContractRegistry for non-IaC plugins). Without this
// bridge, wfctl's NewExternalPluginAdapter fails with
// "unknown service workflow.plugin.v1.PluginService" when it calls
// GetContractRegistry, blocking the typedIaCAdapter load path.
//
// Guard: skip registration if PluginService is already on the server
// (e.g. a mixed plugin that called sdk.Serve AND RegisterAllIaC).
// gRPC panics on double-registration; the guard prevents that.
if _, alreadyRegistered := s.GetServiceInfo()[pb.PluginService_ServiceDesc.ServiceName]; !alreadyRegistered {
pb.RegisterPluginServiceServer(s, &iacPluginServiceBridge{grpcSrv: s})
}
return nil
}

// iacPluginServiceBridge is a minimal pb.PluginServiceServer registered on
// the gRPC server by RegisterAllIaCProviderServices. It implements only
// GetContractRegistry, delegating to BuildContractRegistry so the wfctl
// host can discover which typed IaC services the plugin registered.
//
// All other PluginService methods (InvokeService, GetManifest, etc.) remain
// unimplemented (via UnimplementedPluginServiceServer) — strict-cutover IaC
// plugins do not support string-dispatch or module/step/trigger contracts.
type iacPluginServiceBridge struct {
pb.UnimplementedPluginServiceServer
grpcSrv *grpc.Server
}

// GetContractRegistry returns the set of gRPC services registered on
// grpcSrv at call time, encoded as a *pb.ContractRegistry. wfctl uses
// this to gate optional typed-client construction (Enumerator, DriftDetector,
// etc.) after loading an IaC plugin via discoverAndLoadIaCProvider.
func (b *iacPluginServiceBridge) GetContractRegistry(_ context.Context, _ *emptypb.Empty) (*pb.ContractRegistry, error) {
return BuildContractRegistry(b.grpcSrv), nil
}

// IaCServeOptions configures the IaC plugin gRPC server entrypoint.
//
// Plugin authors typically zero-value this; ServeIaCPlugin then uses the
Expand Down
86 changes: 86 additions & 0 deletions plugin/external/sdk/iacserver_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package sdk_test

import (
"context"
"net"
"strings"
"testing"

"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/emptypb"

pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
"github.com/GoCodeAlone/workflow/plugin/external/sdk"
Expand Down Expand Up @@ -147,3 +151,85 @@ type allCapabilitiesStub struct {

// emptyStub satisfies no IaC interface; the helper must reject it.
type emptyStub struct{}

// TestRegisterAllIaCProviderServices_PluginServiceBridgeRegistered asserts
// that after calling RegisterAllIaCProviderServices, the server also exposes
// "workflow.plugin.v1.PluginService" so the wfctl host can call
// GetContractRegistry without getting "unknown service". This is the fix for
// the DO plugin v1.0.0 incompatibility where ServeIaCPlugin (which calls
// RegisterAllIaCProviderServices) didn't register PluginService, causing
// wfctl's NewExternalPluginAdapter to fail.
func TestRegisterAllIaCProviderServices_PluginServiceBridgeRegistered(t *testing.T) {
grpcSrv := grpc.NewServer()
if err := sdk.RegisterAllIaCProviderServices(grpcSrv, &fullProviderStub{}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := grpcSrv.GetServiceInfo()["workflow.plugin.v1.PluginService"]; !ok {
t.Fatalf("PluginService bridge not registered; have: %v", serviceNames(grpcSrv.GetServiceInfo()))
}
}

// TestRegisterAllIaCProviderServices_PluginServiceBridgeAnswersGetContractRegistry
// verifies the bridge returns a ContractRegistry containing the registered
// IaC services when GetContractRegistry is called via a live gRPC client.
// This exercises the end-to-end path that wfctl's NewExternalPluginAdapter
// takes when loading a DO v1.0.0-style plugin via discoverAndLoadIaCProvider.
func TestRegisterAllIaCProviderServices_PluginServiceBridgeAnswersGetContractRegistry(t *testing.T) {
t.Parallel()

// Spin up an in-process gRPC server with the IaC services + bridge.
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen: %v", err)
}
grpcSrv := grpc.NewServer()
if err := sdk.RegisterAllIaCProviderServices(grpcSrv, &allCapabilitiesStub{}); err != nil {
t.Fatalf("register: %v", err)
}
go func() { _ = grpcSrv.Serve(lis) }()
t.Cleanup(func() { grpcSrv.Stop() })

conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("grpc.NewClient: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })

// Call GetContractRegistry via the PluginServiceClient — exactly what
// wfctl's NewExternalPluginAdapter does via pb.NewPluginServiceClient.
client := pb.NewPluginServiceClient(conn)
registry, err := client.GetContractRegistry(context.Background(), &emptypb.Empty{})
if err != nil {
t.Fatalf("GetContractRegistry: %v — PluginService bridge did not answer (DO v1.0.0 incompatibility fix is broken)", err)
}

services := map[string]bool{}
for _, c := range registry.GetContracts() {
if c.GetKind() == pb.ContractKind_CONTRACT_KIND_SERVICE {
services[c.GetServiceName()] = true
}
}

// The IaCProviderRequired service MUST appear — this is what wfctl's
// buildTypedIaCAdapterFrom checks via registeredIaCServices().
if !services["workflow.plugin.external.iac.IaCProviderRequired"] {
t.Errorf("GetContractRegistry did not include IaCProviderRequired; got services: %v", services)
}
}

// TestRegisterAllIaCProviderServices_PluginServiceAlreadyRegistered_NoPanic
// asserts that calling RegisterAllIaCProviderServices on a server that already
// has PluginService registered (e.g. a mixed plugin using both sdk.Serve and
// RegisterAllIaCProviderServices) does NOT panic from double-registration.
func TestRegisterAllIaCProviderServices_PluginServiceAlreadyRegistered_NoPanic(t *testing.T) {
grpcSrv := grpc.NewServer()
// Pre-register PluginService (simulates a mixed sdk.Serve + IaC plugin).
// Use an embedded-by-value stub so the pattern is idiomatic Go and not
// a pointer-to-unimplemented (which the generated gRPC code warns against).
type minimalPluginSvc struct{ pb.UnimplementedPluginServiceServer }
pb.RegisterPluginServiceServer(grpcSrv, &minimalPluginSvc{})
// RegisterAllIaCProviderServices must not panic on double-registration.
Comment on lines +225 to +231
if err := sdk.RegisterAllIaCProviderServices(grpcSrv, &fullProviderStub{}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
Loading