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
16 changes: 15 additions & 1 deletion module/iac_module.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"fmt"

"github.com/GoCodeAlone/modular"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// IaCModule registers an IaCStateStore in the service registry.
Expand Down Expand Up @@ -93,7 +95,19 @@ func (m *IaCModule) Init(app modular.Application) error {
// The engine populates iacStateBackendRegistryInstance at plugin-load
// time; a resolved backend is served over gRPC via grpcIaCStateStore.
if client, ok := iacStateBackendRegistryInstance.resolve(m.backend); ok {
m.store = newGRPCIaCStateStore(client)
store := newGRPCIaCStateStore(client)
if err := store.Configure(context.Background(), m.backend, m.config); err != nil {
// codes.Unimplemented means the loaded plugin is an older build
// without the Configure RPC — co-deploy requirement of
// decisions/0036. Give the operator an actionable upgrade hint.
if status.Code(err) == codes.Unimplemented {
return fmt.Errorf("iac.state %q: backend %q: the loaded plugin does not implement the "+
"Configure RPC — upgrade the backend plugin to a version that supports Configure "+
"(see decisions/0036): %w", m.name, m.backend, err)
}
return fmt.Errorf("iac.state %q: backend %q: configure plugin backend: %w", m.name, m.backend, err)
}
Comment on lines +98 to +109

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in 6e17cb5IaCModule.Init() now detects status.Code(err) == codes.Unimplemented from the Configure call and returns an actionable "upgrade the backend plugin" error citing decisions/0036; other gRPC codes keep the generic path. New TestIaCModuleConfigureUnimplemented covers it.

m.store = store
break
}
return fmt.Errorf("iac.state %q: backend %q is not built into workflow core "+
Expand Down
156 changes: 156 additions & 0 deletions module/iac_module_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package module

import (
"context"
"errors"
"reflect"
"strings"
"testing"

pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// configureStateBackendClient is a pb.IaCStateBackendClient stub for the
// IaCModule.Init() Configure-wiring tests: it records the Configure request it
// received and can be told to fail.
type configureStateBackendClient struct {
gotConfigure *pb.ConfigureRequest
configureErr error
}

func (c *configureStateBackendClient) Configure(_ context.Context, r *pb.ConfigureRequest, _ ...grpc.CallOption) (*pb.ConfigureResponse, error) {
c.gotConfigure = r
if c.configureErr != nil {
return nil, c.configureErr
}
return &pb.ConfigureResponse{}, nil
}
func (*configureStateBackendClient) GetState(context.Context, *pb.GetStateRequest, ...grpc.CallOption) (*pb.GetStateResponse, error) {
return nil, nil
}
func (*configureStateBackendClient) SaveState(context.Context, *pb.SaveStateRequest, ...grpc.CallOption) (*pb.SaveStateResponse, error) {
return nil, nil
}
func (*configureStateBackendClient) ListStates(context.Context, *pb.ListStatesRequest, ...grpc.CallOption) (*pb.ListStatesResponse, error) {
return nil, nil
}
func (*configureStateBackendClient) DeleteState(context.Context, *pb.DeleteStateRequest, ...grpc.CallOption) (*pb.DeleteStateResponse, error) {
return nil, nil
}
func (*configureStateBackendClient) Lock(context.Context, *pb.LockRequest, ...grpc.CallOption) (*pb.LockResponse, error) {
return nil, nil
}
func (*configureStateBackendClient) Unlock(context.Context, *pb.UnlockRequest, ...grpc.CallOption) (*pb.UnlockResponse, error) {
return nil, nil
}
func (*configureStateBackendClient) ListBackendNames(context.Context, *pb.ListBackendNamesRequest, ...grpc.CallOption) (*pb.ListBackendNamesResponse, error) {
return nil, nil
}

// TestIaCModuleConfigureWiring asserts IaCModule.Init() calls the plugin
// backend's Configure RPC with the backend name and the JSON-encoded module
// config before the store becomes usable.
func TestIaCModuleConfigureWiring(t *testing.T) {
const backend = "azure_blob_configure_wiring_test"
fake := &configureStateBackendClient{}
if err := iacStateBackendRegistryInstance.register(backend, fake); err != nil {
t.Fatalf("register: %v", err)
}
defer func() {
iacStateBackendRegistryInstance.mu.Lock()
delete(iacStateBackendRegistryInstance.clients, backend)
iacStateBackendRegistryInstance.mu.Unlock()
}()

cfg := map[string]any{"backend": backend, "container": "tfstate", "account": "wf"}
m := NewIaCModule("iac-plugin", cfg)
if err := m.Init(NewMockApplication()); err != nil {
t.Fatalf("Init: %v", err)
}

if fake.gotConfigure == nil {
t.Fatal("Init did not call the backend's Configure RPC")
}
if fake.gotConfigure.BackendName != backend {
t.Fatalf("Configure BackendName = %q, want %q", fake.gotConfigure.BackendName, backend)
}
got, err := jsonBytesToMap(fake.gotConfigure.ConfigJson)
if err != nil {
t.Fatalf("Configure ConfigJson not valid JSON: %v", err)
}
if !reflect.DeepEqual(got, cfg) {
t.Fatalf("Configure ConfigJson = %+v, want %+v", got, cfg)
}
if _, ok := m.store.(*grpcIaCStateStore); !ok {
t.Fatalf("m.store is %T, want *grpcIaCStateStore", m.store)
}
}

// TestIaCModuleConfigureError asserts a Configure RPC failure aborts Init() with
// a wrapped error naming the module and the backend.
func TestIaCModuleConfigureError(t *testing.T) {
const backend = "azure_blob_configure_error_test"
sentinel := errors.New("plugin rejected config")
fake := &configureStateBackendClient{configureErr: sentinel}
if err := iacStateBackendRegistryInstance.register(backend, fake); err != nil {
t.Fatalf("register: %v", err)
}
defer func() {
iacStateBackendRegistryInstance.mu.Lock()
delete(iacStateBackendRegistryInstance.clients, backend)
iacStateBackendRegistryInstance.mu.Unlock()
}()

m := NewIaCModule("iac-plugin", map[string]any{"backend": backend})
err := m.Init(NewMockApplication())
if err == nil {
t.Fatal("Init must fail when the backend's Configure RPC errors")
}
if !errors.Is(err, sentinel) {
t.Fatalf("Init error must wrap the Configure error, got: %v", err)
}
if !strings.Contains(err.Error(), "iac-plugin") || !strings.Contains(err.Error(), backend) {
t.Fatalf("Init error must name the module and backend, got: %v", err)
}
if m.store != nil {
t.Fatalf("m.store must stay nil when Configure fails, got %T", m.store)
}
}

// TestIaCModuleConfigureUnimplemented asserts that when the backend plugin is an
// older build whose Configure RPC returns gRPC codes.Unimplemented, Init() fails
// with an actionable error telling the operator to upgrade the plugin.
func TestIaCModuleConfigureUnimplemented(t *testing.T) {
const backend = "azure_blob_configure_unimplemented_test"
unimpl := status.Error(codes.Unimplemented, "method Configure not implemented")
fake := &configureStateBackendClient{configureErr: unimpl}
if err := iacStateBackendRegistryInstance.register(backend, fake); err != nil {
t.Fatalf("register: %v", err)
}
defer func() {
iacStateBackendRegistryInstance.mu.Lock()
delete(iacStateBackendRegistryInstance.clients, backend)
iacStateBackendRegistryInstance.mu.Unlock()
}()

m := NewIaCModule("iac-plugin", map[string]any{"backend": backend})
err := m.Init(NewMockApplication())
if err == nil {
t.Fatal("Init must fail when the backend's Configure RPC is Unimplemented")
}
if !errors.Is(err, unimpl) {
t.Fatalf("Init error must wrap the Unimplemented error, got: %v", err)
}
if !strings.Contains(err.Error(), "iac-plugin") || !strings.Contains(err.Error(), backend) {
t.Fatalf("Init error must name the module and backend, got: %v", err)
}
if !strings.Contains(err.Error(), "upgrade") {
t.Fatalf("Unimplemented error must tell the operator to upgrade the plugin, got: %v", err)
}
if m.store != nil {
t.Fatalf("m.store must stay nil when Configure fails, got %T", m.store)
}
}
13 changes: 13 additions & 0 deletions module/iac_state_grpc_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,19 @@ func newGRPCIaCStateStore(c pb.IaCStateBackendClient) *grpcIaCStateStore {
return &grpcIaCStateStore{client: c}
}

// Configure delivers the iac.state module's YAML config to the plugin-served
// backend so it can construct its SDK-backed store. cfg is JSON-encoded — the
// iac.proto hard invariant — and backendName selects which backend the config
// targets. See decisions/0036.
func (s *grpcIaCStateStore) Configure(ctx context.Context, backendName string, cfg map[string]any) error {
cfgJSON, err := json.Marshal(cfg)
if err != nil {
return err
}
_, err = s.client.Configure(ctx, &pb.ConfigureRequest{BackendName: backendName, ConfigJson: cfgJSON})
return err
}

// GetState retrieves a state record by resource ID. Returns nil, nil when the
// backend reports the record does not exist.
func (s *grpcIaCStateStore) GetState(ctx context.Context, resourceID string) (*IaCState, error) {
Expand Down
56 changes: 56 additions & 0 deletions module/iac_state_grpc_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,62 @@ import (
"google.golang.org/grpc/test/bufconn"
)

// captureStateBackendClient is a pb.IaCStateBackendClient stub that records the
// last Configure request it received. Only Configure is exercised; the other
// methods exist solely to satisfy the interface.
type captureStateBackendClient struct {
gotConfigure *pb.ConfigureRequest
}

func (*captureStateBackendClient) GetState(context.Context, *pb.GetStateRequest, ...grpc.CallOption) (*pb.GetStateResponse, error) {
return nil, nil
}
func (*captureStateBackendClient) SaveState(context.Context, *pb.SaveStateRequest, ...grpc.CallOption) (*pb.SaveStateResponse, error) {
return nil, nil
}
func (*captureStateBackendClient) ListStates(context.Context, *pb.ListStatesRequest, ...grpc.CallOption) (*pb.ListStatesResponse, error) {
return nil, nil
}
func (*captureStateBackendClient) DeleteState(context.Context, *pb.DeleteStateRequest, ...grpc.CallOption) (*pb.DeleteStateResponse, error) {
return nil, nil
}
func (*captureStateBackendClient) Lock(context.Context, *pb.LockRequest, ...grpc.CallOption) (*pb.LockResponse, error) {
return nil, nil
}
func (*captureStateBackendClient) Unlock(context.Context, *pb.UnlockRequest, ...grpc.CallOption) (*pb.UnlockResponse, error) {
return nil, nil
}
func (*captureStateBackendClient) ListBackendNames(context.Context, *pb.ListBackendNamesRequest, ...grpc.CallOption) (*pb.ListBackendNamesResponse, error) {
return nil, nil
}
func (c *captureStateBackendClient) Configure(_ context.Context, r *pb.ConfigureRequest, _ ...grpc.CallOption) (*pb.ConfigureResponse, error) {
c.gotConfigure = r
return &pb.ConfigureResponse{}, nil
}

func TestGRPCIaCStateStoreConfigure(t *testing.T) {
fake := &captureStateBackendClient{}
store := newGRPCIaCStateStore(fake)

cfg := map[string]any{"container": "tfstate", "account": "wf"}
if err := store.Configure(context.Background(), "azure_blob", cfg); err != nil {
t.Fatalf("Configure: %v", err)
}
if fake.gotConfigure == nil {
t.Fatal("Configure did not call the client")
}
if fake.gotConfigure.BackendName != "azure_blob" {
t.Fatalf("BackendName = %q, want azure_blob", fake.gotConfigure.BackendName)
}
got, err := jsonBytesToMap(fake.gotConfigure.ConfigJson)
if err != nil {
t.Fatalf("ConfigJson not valid JSON: %v", err)
}
if got["container"] != "tfstate" || got["account"] != "wf" {
t.Fatalf("ConfigJson round-trip mismatch: %+v", got)
}
}

func TestGRPCIaCStateStoreRoundTrip(t *testing.T) {
lis := bufconn.Listen(4 << 20)
t.Cleanup(func() { _ = lis.Close() })
Expand Down
3 changes: 3 additions & 0 deletions module/iac_state_plugin_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import (
// tests only need it to satisfy the interface; no method is ever called.
type fakeStateBackendClient struct{}

func (*fakeStateBackendClient) Configure(context.Context, *pb.ConfigureRequest, ...grpc.CallOption) (*pb.ConfigureResponse, error) {
return nil, nil
}
func (*fakeStateBackendClient) GetState(context.Context, *pb.GetStateRequest, ...grpc.CallOption) (*pb.GetStateResponse, error) {
return nil, nil
}
Expand Down
Loading
Loading