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
15 changes: 15 additions & 0 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,21 @@ func (e *StdEngine) loadPluginInternal(p plugin.EnginePlugin, allowOverride bool
setter.SetLogger(sl)
}
}
// Register any iac.state backends the plugin serves into module's
// package-level registry, so `iac.state` configs with
// `backend: <name>` dispatch to the plugin-served gRPC backend.
// Amendment A2 (decisions/0035).
if sb, ok := p.(plugin.IaCStateBackendProvider); ok {
clients, err := sb.IaCStateBackendClients()
if err != nil {
return fmt.Errorf("load plugin %q: iac.state backends: %w", p.EngineManifest().Name, err)
}
for name, client := range clients {
if err := module.RegisterIaCStateBackend(name, client); err != nil {
return fmt.Errorf("load plugin %q: %w", p.EngineManifest().Name, err)
}
}
Comment on lines +331 to +340
}
e.enginePlugins = append(e.enginePlugins, p)
return nil
}
Expand Down
14 changes: 12 additions & 2 deletions module/iac_state_plugin_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ func (r *iacStateBackendRegistry) resolve(name string) (pb.IaCStateBackendClient
}

// iacStateBackendRegistryInstance is the package-level singleton the engine
// populates and IaCModule.Init consults. Task 14 adds an exported
// RegisterIaCStateBackend wrapper around it.
// populates and IaCModule.Init consults.
var iacStateBackendRegistryInstance = newIaCStateBackendRegistry()

// RegisterIaCStateBackend associates an iac.state backend name with a
// plugin-served gRPC client in the package-level registry. The engine calls
// this at plugin-load for each backend name a loaded plugin advertises;
// IaCModule.Init then resolves `backend: <name>` configs against it. Reserved
// core backend names (memory/filesystem/postgres) and empty names / nil clients
// are rejected — see iacStateBackendRegistry.register. Amendment A2
// (decisions/0035).
func RegisterIaCStateBackend(name string, client pb.IaCStateBackendClient) error {
return iacStateBackendRegistryInstance.register(name, client)
}
22 changes: 22 additions & 0 deletions module/iac_state_plugin_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,28 @@ func TestIaCStateBackendRegistry(t *testing.T) {
}
}

// TestRegisterIaCStateBackend exercises the exported wrapper the engine calls at
// plugin-load: a non-reserved name registers into the package-level singleton and
// resolves; a reserved core name is rejected.
func TestRegisterIaCStateBackend(t *testing.T) {
const backend = "azure_blob_wrapper_test"
fake := &fakeStateBackendClient{}
if err := RegisterIaCStateBackend(backend, fake); err != nil {
t.Fatalf("RegisterIaCStateBackend(%q): %v", backend, err)
}
defer func() {
iacStateBackendRegistryInstance.mu.Lock()
delete(iacStateBackendRegistryInstance.clients, backend)
iacStateBackendRegistryInstance.mu.Unlock()
}()
if got, ok := iacStateBackendRegistryInstance.resolve(backend); !ok || got != fake {
t.Fatalf("resolve(%q): ok=%v got=%v", backend, ok, got)
}
if err := RegisterIaCStateBackend("memory", fake); err == nil {
t.Fatal("RegisterIaCStateBackend(\"memory\") must fail — reserved core backend name")
}
}

// TestIaCModule_PluginBackendDispatch exercises the real IaCModule.Init() path:
// a backend name no in-process switch case matches is resolved from the
// package-level iacStateBackendRegistryInstance, yielding a *grpcIaCStateStore.
Expand Down
109 changes: 109 additions & 0 deletions plugin/external/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"path/filepath"
"time"

"github.com/GoCodeAlone/modular"
"github.com/GoCodeAlone/workflow/capability"
Expand Down Expand Up @@ -615,5 +616,113 @@ func (a *ExternalPluginAdapter) ConfigTransformHooks() []plugin.ConfigTransformH
}
}

// iacStateBackendServiceName is the fully-qualified gRPC service the plugin's
// ContractRegistry must advertise for the adapter to be treated as an
// iac.state backend provider. Sourced from the generated proto's ServiceDesc
// so it cannot drift if the proto package path/service name ever changes.
var iacStateBackendServiceName = pb.IaCStateBackend_ServiceDesc.ServiceName

// advertisesIaCStateBackendService reports whether the adapter's ContractRegistry
// carries a CONTRACT_KIND_SERVICE descriptor for the IaCStateBackend service.
func (a *ExternalPluginAdapter) advertisesIaCStateBackendService() bool {
if a.contractRegistry == nil {
return false
}
for _, d := range a.contractRegistry.Contracts {
if d == nil {
continue
}
if d.Kind == pb.ContractKind_CONTRACT_KIND_SERVICE && d.ServiceName == iacStateBackendServiceName {
return true
}
}
return false
}

// IaCStateBackendClients implements plugin.IaCStateBackendProvider. At
// plugin-load the engine type-asserts the adapter against that interface and
// registers each returned (name → client) pair into module's iac.state backend
// registry. Amendment A2 (decisions/0035).
//
// Behaviour:
// - If the plugin's ContractRegistry does not advertise the IaCStateBackend
// service: when the disk manifest declares a non-empty IaCStateBackends
// list, that is a silent misconfiguration (the plugin claims backends but
// the host would register none) — return an error so plugin-load fails
// loudly. When the manifest is also silent, the plugin genuinely serves no
// state backend — return (nil, nil); the engine type-assert still succeeds
// and just registers nothing.
// - Otherwise call the live ListBackendNames RPC for the authoritative
// backend-name list and cross-check it against the plugin's declared
// PluginManifest.IaCStateBackends.
//
// Cross-check decision: the RPC is the live source of truth. The manifest is
// only consulted as a declared-vs-served consistency guard — when the manifest
// declares a non-empty backend set, it MUST match the RPC result exactly (a
// plugin whose live RPC contradicts its declared manifest is misconfigured and
// is rejected). When the manifest is silent (no diskManifest, or an empty
// IaCStateBackends list — e.g. a strict-cutover plugin that left GetManifest
// unimplemented and whose plugin.json omits the field) the RPC result is
// accepted on its own.
func (a *ExternalPluginAdapter) IaCStateBackendClients() (map[string]pb.IaCStateBackendClient, error) {
if !a.advertisesIaCStateBackendService() {
Comment thread
intel352 marked this conversation as resolved.
if a.diskManifest != nil && len(a.diskManifest.IaCStateBackends) > 0 {
return nil, fmt.Errorf(
"plugin %s: manifest declares iac.state backends %v but the plugin does not advertise the IaCStateBackend service",
a.name, a.diskManifest.IaCStateBackends)
}
return nil, nil
}
conn := a.Conn()
if conn == nil {
return nil, fmt.Errorf("plugin %s advertises the IaCStateBackend service but has no gRPC connection", a.name)
}
client := pb.NewIaCStateBackendClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := client.ListBackendNames(ctx, &pb.ListBackendNamesRequest{})
if err != nil {
return nil, fmt.Errorf("plugin %s: ListBackendNames RPC: %w", a.name, err)
}
rpcNames := resp.GetBackendNames()
if len(rpcNames) == 0 {
return nil, fmt.Errorf("plugin %s advertises the IaCStateBackend service but ListBackendNames returned no names", a.name)
}
// Cross-check against the declared manifest when it declares any backends.
if a.diskManifest != nil && len(a.diskManifest.IaCStateBackends) > 0 {
if !sameStringSet(rpcNames, a.diskManifest.IaCStateBackends) {
return nil, fmt.Errorf(
"plugin %s: iac.state backend mismatch — ListBackendNames RPC returned %v but manifest declares %v",
a.name, rpcNames, a.diskManifest.IaCStateBackends)
}
}
clients := make(map[string]pb.IaCStateBackendClient, len(rpcNames))
for _, name := range rpcNames {
clients[name] = client
}
return clients, nil
}

// sameStringSet reports whether a and b contain the same set of strings,
// ignoring order and duplicates.
func sameStringSet(a, b []string) bool {
set := make(map[string]struct{}, len(a))
for _, s := range a {
set[s] = struct{}{}
}
seen := make(map[string]struct{}, len(b))
for _, s := range b {
if _, ok := set[s]; !ok {
return false
}
seen[s] = struct{}{}
}
return len(seen) == len(set)
}

// Ensure ExternalPluginAdapter satisfies plugin.EnginePlugin at compile time.
var _ plugin.EnginePlugin = (*ExternalPluginAdapter)(nil)

// Ensure ExternalPluginAdapter satisfies plugin.IaCStateBackendProvider at
// compile time — the engine type-asserts loaded plugins against it.
var _ plugin.IaCStateBackendProvider = (*ExternalPluginAdapter)(nil)
118 changes: 118 additions & 0 deletions plugin/external/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package external
import (
"context"
"errors"
"net"
"strings"
"testing"

"github.com/GoCodeAlone/workflow/plugin"
pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/reflect/protodesc"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
Expand Down Expand Up @@ -1112,3 +1115,118 @@ func TestEngineManifestValidatesAfterDiskOverlay(t *testing.T) {
t.Fatalf("EngineManifest().Validate(): %v", err)
}
}

// fakeIaCStateBackendServer serves a fixed ListBackendNames result for the
// IaCStateBackendClients() adapter tests.
type fakeIaCStateBackendServer struct {
pb.UnimplementedIaCStateBackendServer
names []string
}

func (s *fakeIaCStateBackendServer) ListBackendNames(context.Context, *pb.ListBackendNamesRequest) (*pb.ListBackendNamesResponse, error) {
return &pb.ListBackendNamesResponse{BackendNames: s.names}, nil
}

// newIaCStateBackendTestAdapter stands up an in-process gRPC server serving
// IaCStateBackend with the given backend names, and returns an
// ExternalPluginAdapter wired to it. The adapter's ContractRegistry advertises
// the IaCStateBackend service iff advertise is true; diskManifest carries the
// declared backend names (nil = silent manifest).
func newIaCStateBackendTestAdapter(t *testing.T, advertise bool, served []string, diskBackends []string) *ExternalPluginAdapter {
t.Helper()
lis := bufconn.Listen(4 << 20)
t.Cleanup(func() { _ = lis.Close() })
srv := grpc.NewServer()
pb.RegisterIaCStateBackendServer(srv, &fakeIaCStateBackendServer{names: served})
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)

conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { return lis.DialContext(ctx) }),
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("grpc.NewClient: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })

registry := &pb.ContractRegistry{}
if advertise {
registry.Contracts = []*pb.ContractDescriptor{
{Kind: pb.ContractKind_CONTRACT_KIND_SERVICE, ServiceName: iacStateBackendServiceName, Method: "ListBackendNames"},
}
}
var dm *plugin.PluginManifest
if diskBackends != nil {
dm = &plugin.PluginManifest{Name: "iac-state-plugin", IaCStateBackends: diskBackends}
}
return &ExternalPluginAdapter{
name: "iac-state-plugin",
manifest: &pb.Manifest{Name: "iac-state-plugin"},
diskManifest: dm,
contractRegistry: registry,
client: &PluginClient{conn: conn},
}
}

func TestIaCStateBackendClients_AdvertisedAndManifestAgree(t *testing.T) {
a := newIaCStateBackendTestAdapter(t, true, []string{"azure_blob"}, []string{"azure_blob"})
clients, err := a.IaCStateBackendClients()
if err != nil {
t.Fatalf("IaCStateBackendClients: %v", err)
}
if len(clients) != 1 {
t.Fatalf("len(clients) = %d, want 1 (%v)", len(clients), clients)
}
if clients["azure_blob"] == nil {
t.Fatalf("clients[azure_blob] is nil; got map %v", clients)
}
}

func TestIaCStateBackendClients_SilentManifestAcceptsRPC(t *testing.T) {
// diskManifest nil → manifest is silent → RPC result accepted on its own.
a := newIaCStateBackendTestAdapter(t, true, []string{"azure_blob"}, nil)
clients, err := a.IaCStateBackendClients()
if err != nil {
t.Fatalf("IaCStateBackendClients: %v", err)
}
if len(clients) != 1 || clients["azure_blob"] == nil {
t.Fatalf("clients = %v, want one azure_blob entry", clients)
}
}

func TestIaCStateBackendClients_RPCAndManifestDisagree(t *testing.T) {
a := newIaCStateBackendTestAdapter(t, true, []string{"azure_blob"}, []string{"gcs"})
_, err := a.IaCStateBackendClients()
if err == nil {
t.Fatal("IaCStateBackendClients must error when RPC and manifest disagree")
}
if !strings.Contains(err.Error(), "mismatch") {
t.Fatalf("error %q should mention the mismatch", err)
}
}

func TestIaCStateBackendClients_NoServiceAdvertised(t *testing.T) {
// Plugin advertises no IaCStateBackend service AND its manifest is silent →
// (nil, nil), no RPC made.
a := newIaCStateBackendTestAdapter(t, false, []string{"azure_blob"}, nil)
clients, err := a.IaCStateBackendClients()
if err != nil {
t.Fatalf("IaCStateBackendClients: %v", err)
}
if clients != nil {
t.Fatalf("clients = %v, want nil when no IaCStateBackend service is advertised", clients)
}
}

func TestIaCStateBackendClients_ManifestDeclaresButServiceNotAdvertised(t *testing.T) {
// Manifest declares backends but the plugin advertises no IaCStateBackend
// service → silent misconfiguration → plugin-load must fail loudly.
a := newIaCStateBackendTestAdapter(t, false, nil, []string{"azure_blob"})
_, err := a.IaCStateBackendClients()
if err == nil {
t.Fatal("IaCStateBackendClients must error when the manifest declares backends but the service is not advertised")
}
if !strings.Contains(err.Error(), "does not advertise") {
t.Fatalf("error %q should explain the service is not advertised", err)
}
}
11 changes: 11 additions & 0 deletions plugin/iac_state_backend_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package plugin

import proto "github.com/GoCodeAlone/workflow/plugin/external/proto"

// IaCStateBackendProvider is the optional interface an external-plugin adapter
// implements when its plugin serves one or more iac.state backends. The engine
// type-asserts loaded plugins against it (same pattern as stepRegistrySetter)
// and populates module's iac.state backend registry.
Comment on lines +5 to +8
type IaCStateBackendProvider interface {
IaCStateBackendClients() (map[string]proto.IaCStateBackendClient, error)
}
Loading