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: 16 additions & 0 deletions cmd/wfctl/iac_typed_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const (
iacServiceMigrationRepairer = "workflow.plugin.external.iac.IaCProviderMigrationRepairer"
iacServiceValidator = "workflow.plugin.external.iac.IaCProviderValidator"
iacServiceDriftConfigDetect = "workflow.plugin.external.iac.IaCProviderDriftConfigDetector"
iacServiceFinalizer = "workflow.plugin.external.iac.IaCProviderFinalizer"
iacServiceResourceDriver = "workflow.plugin.external.iac.ResourceDriver"
)

Expand All @@ -78,6 +79,7 @@ type typedIaCAdapter struct {
repairer pb.IaCProviderMigrationRepairerClient
validator pb.IaCProviderValidatorClient
driftCfg pb.IaCProviderDriftConfigDetectorClient
finalizer pb.IaCProviderFinalizerClient
resourceDriv pb.ResourceDriverClient

// cachedCaps memoizes the plugin's CapabilitiesResponse. Access via
Expand Down Expand Up @@ -116,6 +118,9 @@ func newTypedIaCAdapter(conn *grpc.ClientConn, registered map[string]bool) *type
if registered[iacServiceDriftConfigDetect] {
a.driftCfg = pb.NewIaCProviderDriftConfigDetectorClient(conn)
}
if registered[iacServiceFinalizer] {
a.finalizer = pb.NewIaCProviderFinalizerClient(conn)
}
if registered[iacServiceResourceDriver] {
a.resourceDriv = pb.NewResourceDriverClient(conn)
}
Expand Down Expand Up @@ -220,6 +225,17 @@ func (a *typedIaCAdapter) ResourceDriverClient() pb.ResourceDriverClient {
return a.resourceDriv
}

// Finalizer returns the typed pb.IaCProviderFinalizerClient or nil when
// the plugin did not register IaCProviderFinalizer. Used by the v2 apply
// path's statePersistenceHooks helper (cmd/wfctl/infra_apply.go) to gate
// the ApplyPlanHooks.OnPlanComplete wiring on service-presence — a nil
// return means no FinalizeApply RPC is invoked. Per ADR 0024 the absence
// of the registration is the negative signal (no compat shim, no
// NotSupported flag). Per workflow#695 Phase 2.5.
func (a *typedIaCAdapter) Finalizer() pb.IaCProviderFinalizerClient {
return a.finalizer
}

// translateRPCErr converts a gRPC Unimplemented status (the wire signal a
// plugin emits when an optional method is not supported) into the stable
// interfaces.ErrProviderMethodUnimplemented sentinel callers iterate on
Expand Down
66 changes: 66 additions & 0 deletions cmd/wfctl/iac_typed_adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,72 @@ func (p *countingCapabilitiesProvider) Capabilities(_ context.Context, _ *pb.Cap
return &pb.CapabilitiesResponse{ComputePlanVersion: p.computePlanVersion}, nil
}

// ─── IaCProviderFinalizer accessor tests (workflow#695 Phase 2.5) ──────────

// TestTypedAdapter_Finalizer_PopulatedWhenRegistered verifies that
// newTypedIaCAdapter wires the pb.IaCProviderFinalizerClient when the
// plugin's ContractRegistry advertised the IaCProviderFinalizer service.
// Per workflow#695 Phase 2.5 / ADR 0024 (service-presence is the opt-in
// signal — no NotSupported flag, no compat shim).
func TestTypedAdapter_Finalizer_PopulatedWhenRegistered(t *testing.T) {
conn := dialLazyConn(t)
adapter := newTypedIaCAdapter(conn, map[string]bool{
iacServiceFinalizer: true,
})
if adapter.Finalizer() == nil {
t.Error("Finalizer() returned nil when IaCProviderFinalizer is in registered set")
}
}

// TestTypedAdapter_Finalizer_NilWhenNotRegistered verifies the negative
// signal — when the plugin did not advertise IaCProviderFinalizer, the
// accessor returns nil so the wfctl-side OnPlanComplete wiring in
// statePersistenceHooks (cmd/wfctl/infra_apply.go) stays unset and no
// finalize RPC is invoked. Locks the contract that downstream consumers
// gate on.
func TestTypedAdapter_Finalizer_NilWhenNotRegistered(t *testing.T) {
conn := dialLazyConn(t)
adapter := newTypedIaCAdapter(conn, map[string]bool{
iacServiceEnumerator: true, // arbitrary other service, no Finalizer
})
if adapter.Finalizer() != nil {
t.Error("Finalizer() returned non-nil when IaCProviderFinalizer not registered")
}
}

// dialLazyConn returns a real *grpc.ClientConn pointing at an in-process
// gRPC server with zero services registered. Used by adapter field-wiring
// tests that need newTypedIaCAdapter's `pb.NewXxxClient(conn)` calls to
// succeed without invoking any RPC. Spinning up a real listener (vs
// relying on grpc-go's NewClient-defers-dial behavior) keeps the helper
// robust against future grpc-go releases that might switch to eager
// dialing — the conn dials a live but service-empty server, so the
// field-wiring assertion always represents what we mean to test (a real
// dial-back conn) rather than a happens-to-be-deferred sentinel.
// t.Cleanup drains both server + conn so test isolation is preserved.
func dialLazyConn(t *testing.T) *grpc.ClientConn {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen: %v", err)
}
srv := grpc.NewServer()
go func() { _ = srv.Serve(lis) }()
conn, err := grpc.NewClient(
lis.Addr().String(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
srv.Stop()
t.Fatalf("grpc.NewClient: %v", err)
}
t.Cleanup(func() {
_ = conn.Close()
srv.Stop()
})
return conn
}

// 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
60 changes: 58 additions & 2 deletions cmd/wfctl/infra_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/GoCodeAlone/workflow/iac/wfctlhelpers"
"github.com/GoCodeAlone/workflow/interfaces"
"github.com/GoCodeAlone/workflow/platform"
pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
"github.com/GoCodeAlone/workflow/secrets"
)

Expand Down Expand Up @@ -469,7 +470,7 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi
// call site (per dispatch.go contract).
if wfctlhelpers.DispatchVersionFor(provider) == wfctlhelpers.DispatchVersionV2 {
usedV2Dispatch = true
hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, hydratedOut)
hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut)
result, err = applyV2ApplyPlanWithHooksFn(ctx, provider, &plan, hooks)
// printDriftReportIfAny was added unwired in W-3a/T3.1.5; the
// v2 dispatch is the production caller that surfaces input
Expand Down Expand Up @@ -594,6 +595,7 @@ func statePersistenceHooks(
secretsProvider secrets.Provider,
provider interfaces.IaCProvider,
providerType string,
planID string,
hydratedOut map[string]string,
) wfctlhelpers.ApplyPlanHooks {
return wfctlhelpers.ApplyPlanHooks{
Expand All @@ -613,6 +615,60 @@ func statePersistenceHooks(
OnResourceDeleted: func(ctx context.Context, action interfaces.PlanAction) error {
return deleteStateAfterCloudDelete(store, action.Resource.Name)
},
// OnPlanComplete is the workflow#695 Phase 2.5 hook that bridges
// the v2 apply path to the plugin's optional IaCProviderFinalizer
// service. Fires exactly once on the natural success-exit return
// of applyPlanWithEnvProviderAndHooks (v1 semantic preservation
// per cycle-1 plan-review C-3 — see ApplyPlanHooks.OnPlanComplete
// godoc for fire/no-fire enumeration).
//
// No-op paths (preserve pre-Phase-2.5 behavior):
// - provider is not a *typedIaCAdapter (in-process fakes,
// legacy provider shapes): no Finalizer() accessor available;
// skip silently.
// - adapter.Finalizer() returns nil (plugin did not register
// IaCProviderFinalizer per ADR 0024): skip silently. Plugins
// opt in via service registration; absence = no firing.
//
// Fire path: invoke FinalizeApply RPC; on gRPC transport error
// surface wrapped; on per-driver errors in response, aggregate
// the per-driver attribution into a single err message that
// preserves the Resource/Action/Error shape from the v1 wrapper
// (per ADR 0040 invariant on per-driver attribution). The engine
// closure in apply.go's deferred OnPlanComplete handler appends
// the returned err to result.Errors as the "<plan-finalize>"
// entry and surfaces wrapped to outer caller err.
OnPlanComplete: func(ctx context.Context) error {
adapter, ok := provider.(*typedIaCAdapter)
if !ok {
return nil
}
fin := adapter.Finalizer()
if fin == nil {
return nil
}
resp, callErr := fin.FinalizeApply(ctx, &pb.FinalizeApplyRequest{PlanId: planID})
if callErr != nil {
return fmt.Errorf("FinalizeApply gRPC: %w", callErr)
}
if len(resp.GetErrors()) > 0 {
msgs := make([]string, 0, len(resp.GetErrors()))
for _, e := range resp.GetErrors() {
// Field order is Resource/Action (matches proto field
// declaration order in ActionError + apply.go's
// ActionError construction). NOTE: applyWithProviderAndStore's
// existing per-resource aggregator above uses the inverse
// Action/Resource order — pre-existing file-level
// inconsistency, not introduced here. Reconciliation
// (flipping the older site to Resource/Action canonical
// order) is tracked separately; do NOT "fix" this site
// back to Action/Resource without flipping the other too.
msgs = append(msgs, fmt.Sprintf("%s/%s: %s", e.GetResource(), e.GetAction(), e.GetError()))
}
return fmt.Errorf("plugin finalize: %d driver(s) failed: %s", len(resp.GetErrors()), strings.Join(msgs, "; "))
}
return nil
},
}
}

Expand Down Expand Up @@ -1606,7 +1662,7 @@ func applyPrecomputedPlanWithStore(ctx context.Context, plan interfaces.IaCPlan,
var usedV2Dispatch bool
if wfctlhelpers.DispatchVersionFor(provider) == wfctlhelpers.DispatchVersionV2 {
usedV2Dispatch = true
hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, hydratedOut)
hooks := statePersistenceHooks(store, secretsProvider, provider, providerType, plan.ID, hydratedOut)
result, err = applyV2ApplyPlanWithHooksFn(ctx, provider, &plan, hooks)
if result != nil {
printDriftReportIfAny(w, result)
Expand Down
Loading
Loading