diff --git a/engine.go b/engine.go index 47f28e44..d9e855a9 100644 --- a/engine.go +++ b/engine.go @@ -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: ` 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) + } + } + } e.enginePlugins = append(e.enginePlugins, p) return nil } diff --git a/module/iac_state_plugin_registry.go b/module/iac_state_plugin_registry.go index c1f18685..48054dd9 100644 --- a/module/iac_state_plugin_registry.go +++ b/module/iac_state_plugin_registry.go @@ -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: ` 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) +} diff --git a/module/iac_state_plugin_registry_test.go b/module/iac_state_plugin_registry_test.go index 16b71c21..83719d84 100644 --- a/module/iac_state_plugin_registry_test.go +++ b/module/iac_state_plugin_registry_test.go @@ -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. diff --git a/plugin/external/adapter.go b/plugin/external/adapter.go index 82a0a4e0..4daffb4e 100644 --- a/plugin/external/adapter.go +++ b/plugin/external/adapter.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "path/filepath" + "time" "github.com/GoCodeAlone/modular" "github.com/GoCodeAlone/workflow/capability" @@ -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() { + 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) diff --git a/plugin/external/adapter_test.go b/plugin/external/adapter_test.go index 2d723f81..78f9ab7d 100644 --- a/plugin/external/adapter_test.go +++ b/plugin/external/adapter_test.go @@ -3,6 +3,7 @@ package external import ( "context" "errors" + "net" "strings" "testing" @@ -10,7 +11,9 @@ import ( 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" @@ -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) + } +} diff --git a/plugin/iac_state_backend_provider.go b/plugin/iac_state_backend_provider.go new file mode 100644 index 00000000..a36dcd28 --- /dev/null +++ b/plugin/iac_state_backend_provider.go @@ -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. +type IaCStateBackendProvider interface { + IaCStateBackendClients() (map[string]proto.IaCStateBackendClient, error) +}