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
79 changes: 72 additions & 7 deletions cmd/wfctl/iac_typed_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"

"github.com/GoCodeAlone/workflow/iac/wfctlhelpers"
"github.com/GoCodeAlone/workflow/interfaces"
pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
)
Expand All @@ -60,6 +61,13 @@ const (
// pb.IaC* gRPC clients. Optional clients are nil when the plugin did not
// register the corresponding service — call paths gated on those clients
// return interfaces.ErrProviderMethodUnimplemented.
//
// Capability cache (cachedCaps): the plugin's CapabilitiesResponse is
// fetched lazily on the first call to Capabilities() / SupportedCanonicalKeys()
// / ComputePlanVersion() and reused for the adapter's lifetime. Capabilities
// are advertised once at plugin startup and don't change during a wfctl
// invocation; caching lets per-call accessors (notably the apply-time
// dispatch decision) avoid an RPC round-trip per access. Per ADR-0029.
type typedIaCAdapter struct {
conn *grpc.ClientConn

Expand All @@ -71,6 +79,12 @@ type typedIaCAdapter struct {
validator pb.IaCProviderValidatorClient
driftCfg pb.IaCProviderDriftConfigDetectorClient
resourceDriv pb.ResourceDriverClient

// cachedCaps memoizes the plugin's CapabilitiesResponse. Access via
// fetchCapabilities — never read this field directly.
cachedCaps *pb.CapabilitiesResponse
capsErr error
capsFetch bool // true once first fetch attempt completed (success OR error)
}

// newTypedIaCAdapter builds an adapter from a live gRPC connection plus a
Expand Down Expand Up @@ -277,8 +291,27 @@ func (a *typedIaCAdapter) Initialize(ctx context.Context, config map[string]any)
return err
}

func (a *typedIaCAdapter) Capabilities() []interfaces.IaCCapabilityDeclaration {
// fetchCapabilities returns the plugin's CapabilitiesResponse, caching the
// first result for the adapter's lifetime. RPC errors are also cached so
// repeated accesses don't repeatedly fail against an unreachable plugin.
// Capabilities are advertised at plugin startup and don't change during
// a wfctl invocation; caching is correct + cheap.
func (a *typedIaCAdapter) fetchCapabilities() (*pb.CapabilitiesResponse, error) {
if a.capsFetch {
return a.cachedCaps, a.capsErr
}
a.capsFetch = true
resp, err := a.required.Capabilities(context.Background(), &pb.CapabilitiesRequest{})
if err != nil {
a.capsErr = err
return nil, err
}
a.cachedCaps = resp
return resp, nil
Comment on lines +294 to +310
}

func (a *typedIaCAdapter) Capabilities() []interfaces.IaCCapabilityDeclaration {
resp, err := a.fetchCapabilities()
if err != nil {
return nil
}
Expand Down Expand Up @@ -375,16 +408,42 @@ func (a *typedIaCAdapter) ResourceDriver(resourceType string) (interfaces.Resour
return &typedResourceDriver{client: a.resourceDriv, resourceType: resourceType}, nil
}

// SupportedCanonicalKeys returns the canonical IaC config keys this
// plugin supports. Reads from the cached CapabilitiesResponse:
// - non-empty CapabilitiesResponse.canonical_keys → use those (provider
// declared a strict subset, e.g. DO plugin removing loadbalancer/vpc/k8s)
// - empty list OR Capabilities RPC failure → fall back to
// interfaces.CanonicalKeys() wfctl-side default
//
// Per ADR-0029. Closes the regression where the typed cutover lost the
// per-provider override path that legacy remoteIaCProvider routed via
// InvokeService("SupportedCanonicalKeys", ...).
func (a *typedIaCAdapter) SupportedCanonicalKeys() []string {
// SupportedCanonicalKeys is intentionally absent from the typed proto
// surface — providers declare their canonical-key support through the
// existing ContractRegistry capability flow (Task 5) rather than a
// dedicated RPC. Returning the canonical-keys default keeps engine
// consumers unchanged; provider-level overrides will land via the
// capability registry follow-up.
resp, err := a.fetchCapabilities()
if err == nil && resp != nil {
if keys := resp.GetCanonicalKeys(); len(keys) > 0 {
return append([]string(nil), keys...)
}
}
return interfaces.CanonicalKeys()
}

// ComputePlanVersion returns the apply-time dispatch version the plugin
// declared in CapabilitiesResponse. Empty string (or RPC failure) means
// "v1" by ComputePlanVersionDeclarer convention — DispatchVersionFor
// treats unknown values as v1, so unset cleanly degrades to legacy path.
//
// The presence of this method on *typedIaCAdapter means it satisfies
// wfctlhelpers.ComputePlanVersionDeclarer at compile time, restoring the
// type-assert dispatch parity with legacy remoteIaCProvider. Per ADR-0029.
func (a *typedIaCAdapter) ComputePlanVersion() string {
resp, err := a.fetchCapabilities()
if err != nil || resp == nil {
return ""
}
return resp.GetComputePlanVersion()
}

func (a *typedIaCAdapter) BootstrapStateBackend(ctx context.Context, cfg map[string]any) (*interfaces.BootstrapResult, error) {
cfgJSON, err := marshalJSONMap(cfg)
if err != nil {
Expand Down Expand Up @@ -1277,4 +1336,10 @@ var (
_ interfaces.ProviderMigrationRepairer = (*typedIaCAdapter)(nil)
_ interfaces.ResourceDriver = (*typedResourceDriver)(nil)
_ interfaces.Troubleshooter = (*typedResourceDriver)(nil)
// ADR-0029 capability extension: typedIaCAdapter satisfies
// ComputePlanVersionDeclarer so wfctlhelpers.DispatchVersionFor's
// type-assert dispatch picks up the plugin's declared apply-version
// from the cached CapabilitiesResponse instead of silently falling
// back to "v1".
_ wfctlhelpers.ComputePlanVersionDeclarer = (*typedIaCAdapter)(nil)
)
145 changes: 142 additions & 3 deletions cmd/wfctl/iac_typed_adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import (
"net"
"testing"

"github.com/GoCodeAlone/workflow/iac/wfctlhelpers"

"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
Expand Down Expand Up @@ -228,6 +230,134 @@ func TestTypedAdapter_EndToEnd_NameVersionEnumerateAll(t *testing.T) {

// ─── In-process gRPC test fixture ───────────────────────────────────────────

// ─── ADR-0029 capability-extension tests ─────────────────────────────────

// TestTypedAdapter_SupportedCanonicalKeys_PluginOverride exercises the
// regression closure: plugin declares a strict subset of canonical keys
// in CapabilitiesResponse, adapter returns those (not the wfctl-side
// default).
func TestTypedAdapter_SupportedCanonicalKeys_PluginOverride(t *testing.T) {
provider := &fullStubProvider{
name: "do",
version: "v1.0.0",
canonicalKeys: []string{"infra.spaces", "infra.spaces_key", "infra.droplet"},
}
srv, conn := startTestServer(t, provider, false)
t.Cleanup(srv.Stop)
t.Cleanup(func() { _ = conn.Close() })

adapter := newTypedIaCAdapter(conn, nil)
got := adapter.SupportedCanonicalKeys()
want := []string{"infra.spaces", "infra.spaces_key", "infra.droplet"}
if len(got) != len(want) {
t.Fatalf("SupportedCanonicalKeys returned %d keys; want %d (got=%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("SupportedCanonicalKeys[%d] = %q; want %q", i, got[i], want[i])
}
}
}

// TestTypedAdapter_SupportedCanonicalKeys_FallbackToDefault exercises
// the empty-canonical-keys path: adapter falls back to
// interfaces.CanonicalKeys() so plugins without an override work as
// before. Comparison is set-based since the underlying default's
// iteration order isn't guaranteed.
func TestTypedAdapter_SupportedCanonicalKeys_FallbackToDefault(t *testing.T) {
provider := &fullStubProvider{name: "stub", version: "v0"} // no canonical_keys
srv, conn := startTestServer(t, provider, false)
t.Cleanup(srv.Stop)
t.Cleanup(func() { _ = conn.Close() })

adapter := newTypedIaCAdapter(conn, nil)
got := adapter.SupportedCanonicalKeys()
want := interfaces.CanonicalKeys()
if len(got) != len(want) {
t.Fatalf("SupportedCanonicalKeys returned %d keys; want %d (default fallback)", len(got), len(want))
}
wantSet := make(map[string]bool, len(want))
for _, k := range want {
wantSet[k] = true
}
for _, k := range got {
if !wantSet[k] {
t.Errorf("returned key %q not in interfaces.CanonicalKeys() default set", k)
}
}
}

// TestTypedAdapter_ComputePlanVersion_PluginDeclares verifies
// CapabilitiesResponse.compute_plan_version surfaces through the adapter
// for ComputePlanVersionDeclarer dispatch.
func TestTypedAdapter_ComputePlanVersion_PluginDeclares(t *testing.T) {
provider := &fullStubProvider{name: "do", version: "v1.0.0", computePlanVersion: "v2"}
srv, conn := startTestServer(t, provider, false)
t.Cleanup(srv.Stop)
t.Cleanup(func() { _ = conn.Close() })

adapter := newTypedIaCAdapter(conn, nil)
if got := adapter.ComputePlanVersion(); got != "v2" {
t.Errorf("ComputePlanVersion = %q; want %q", got, "v2")
}

// DispatchVersionFor honors the declaration.
if got := wfctlhelpers.DispatchVersionFor(adapter); got != "v2" {
t.Errorf("DispatchVersionFor = %q; want %q", got, "v2")
}
}

// TestTypedAdapter_ComputePlanVersion_EmptyMeansV1 verifies plugins that
// don't declare compute_plan_version get the legacy "v1" dispatch path
// via DispatchVersionFor's default-on-empty rule.
func TestTypedAdapter_ComputePlanVersion_EmptyMeansV1(t *testing.T) {
provider := &fullStubProvider{name: "stub", version: "v0"} // no compute_plan_version
srv, conn := startTestServer(t, provider, false)
t.Cleanup(srv.Stop)
t.Cleanup(func() { _ = conn.Close() })

adapter := newTypedIaCAdapter(conn, nil)
if got := adapter.ComputePlanVersion(); got != "" {
t.Errorf("ComputePlanVersion = %q; want empty (no declaration)", got)
}
if got := wfctlhelpers.DispatchVersionFor(adapter); got != "v1" {
t.Errorf("DispatchVersionFor = %q; want %q (empty → v1)", got, "v1")
}
}

// TestTypedAdapter_CapabilitiesCacheReusedAcrossCalls verifies the
// CapabilitiesResponse is fetched at most once across repeated accessor
// calls (avoids RPC thrash on the dispatch hot path).
func TestTypedAdapter_CapabilitiesCacheReusedAcrossCalls(t *testing.T) {
provider := &countingCapabilitiesProvider{computePlanVersion: "v2"}
srv, conn := startTestServer(t, provider, false)
t.Cleanup(srv.Stop)
t.Cleanup(func() { _ = conn.Close() })

adapter := newTypedIaCAdapter(conn, nil)
for i := 0; i < 5; i++ {
_ = adapter.ComputePlanVersion()
_ = adapter.SupportedCanonicalKeys()
_ = adapter.Capabilities()
}
if provider.calls != 1 {
t.Errorf("Capabilities RPC called %d times; want 1 (cache miss after first call)", provider.calls)
}
}

// countingCapabilitiesProvider counts Capabilities() RPC invocations to
// verify caching behavior.
type countingCapabilitiesProvider struct {
pb.UnimplementedIaCProviderRequiredServer
computePlanVersion string
calls int
}

func (p *countingCapabilitiesProvider) Capabilities(_ context.Context, _ *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
p.calls++
return &pb.CapabilitiesResponse{ComputePlanVersion: p.computePlanVersion}, nil
}

// startTestServer spins up an in-process gRPC server registered with
// the supplied IaCProviderRequiredServer (and optionally the matching
// enumerator) on a localhost ephemeral port. Returns the server and a
Expand Down Expand Up @@ -267,9 +397,18 @@ type fullStubProvider struct {
pb.UnimplementedIaCProviderRequiredServer
pb.UnimplementedIaCProviderEnumeratorServer

name string
version string
enumerated []*pb.ResourceOutput
name string
version string
enumerated []*pb.ResourceOutput
canonicalKeys []string // ADR-0029: empty = adapter falls back to interfaces.CanonicalKeys()
computePlanVersion string // ADR-0029: empty = adapter returns "" (DispatchVersionFor → "v1")
}

func (s *fullStubProvider) Capabilities(_ context.Context, _ *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
return &pb.CapabilitiesResponse{
CanonicalKeys: s.canonicalKeys,
ComputePlanVersion: s.computePlanVersion,
}, nil
}

func (s *fullStubProvider) Name(_ context.Context, _ *pb.NameRequest) (*pb.NameResponse, error) {
Expand Down
Loading
Loading