Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
324d856
feat(iac): add IaCPlan.SchemaVersion + InputSnapshot + PlanAction.Res…
intel352 May 3, 2026
7c8c3ac
feat(iac): add inputsnapshot.Compute + Snapshot + NewTolerantEnvProvi…
intel352 May 3, 2026
4774c3b
feat(iac): wfctl infra plan writes InputSnapshot to plan.json
intel352 May 3, 2026
295d354
feat(iac): ComputePlan sets PlanAction.ResolvedConfigHash
intel352 May 3, 2026
b442dae
feat(iac): wfctl infra plan warns when plan.json not in .gitignore
intel352 May 3, 2026
43c8ced
feat(iac): typed ErrEnvVarChanged sentinel + plan-stale diagnostic + …
intel352 May 3, 2026
09bfe8e
feat(iac): add refreshoutputs.Refresh — read-only state output refresh
intel352 May 3, 2026
181e579
feat(iac): add wfctl infra refresh-outputs subcommand
intel352 May 4, 2026
bfd1bbe
feat(iac): apply-time refresh-outputs pre-step (opt-in via WFCTL_REFR…
intel352 May 4, 2026
2d77af9
test(iac): concurrency stress test for refreshoutputs.Refresh
intel352 May 4, 2026
a892c1d
docs(wfctl): document infra refresh-outputs subcommand
intel352 May 4, 2026
8dafaa5
docs(adr): record WFCTL_REFRESH_OUTPUTS ParseBool semantics deviation…
intel352 May 4, 2026
695a070
feat(iac): plugin manifest gains iacProvider.computePlanVersion (defa…
intel352 May 4, 2026
4df0d1b
fix(iac): T3.0 review — sync.Once-guarded schema cache + tighter iacP…
intel352 May 4, 2026
5039290
feat(iac): add refreshoutputs.Refresh — read-only state output refresh
intel352 May 3, 2026
dcc1ec2
feat(iac): add wfctl infra refresh-outputs subcommand
intel352 May 4, 2026
207924d
feat(iac): apply-time refresh-outputs pre-step (opt-in via WFCTL_REFR…
intel352 May 4, 2026
8bbe4a4
test(iac): concurrency stress test for refreshoutputs.Refresh
intel352 May 4, 2026
e9a200f
docs(wfctl): document infra refresh-outputs subcommand
intel352 May 4, 2026
f42f536
docs(adr): record WFCTL_REFRESH_OUTPUTS ParseBool semantics deviation…
intel352 May 4, 2026
37185d8
Merge branch 'feat/iac-refresh-outputs' into feat/iac-replace-foundation
intel352 May 4, 2026
13a6fad
feat(iac): add ApplyResult.InitialInputSnapshot + InputDriftReport + …
intel352 May 4, 2026
8416498
feat(iac): add wfctlhelpers.ApplyPlan skeleton (4-action dispatch)
intel352 May 4, 2026
d3073d7
fix(iac): T3.0.4 review — correct ReplaceIDMap key direction + lock o…
intel352 May 4, 2026
6dbe501
fix(iac): T3.1 review — strengthen Replace coverage + ctx-cancel + dr…
intel352 May 4, 2026
04d8ad2
fix(wfctl): drop unused crypto/sha256 + encoding/hex from infra_apply…
intel352 May 4, 2026
f5a7ce9
feat(iac): in-process apply unconditional drift postcondition (panic-…
intel352 May 4, 2026
0c30eec
feat(iac): doCreate honors UpsertSupporter for ErrResourceAlreadyExis…
intel352 May 4, 2026
a3fc98b
feat(iac): doUpdate + doDelete actions
intel352 May 4, 2026
b17d703
feat(iac): doReplace populates ApplyResult.ReplaceIDMap
intel352 May 4, 2026
8774205
feat(iac): add diff cache with LRU eviction + corruption recovery
intel352 May 4, 2026
b735f62
fix(iac): T3.1.5/T3.2/T3.3 review minors — helper consistency, type-a…
intel352 May 4, 2026
1deffae
fix(iac): T3.4 review — ctx-cancel guard between Delete and Create in…
intel352 May 4, 2026
f80a060
docs(iac): document diffcache + set WFCTL_DIFFCACHE=:memory: in CI wo…
intel352 May 4, 2026
5ca9a75
fix(iac): T3.5 review minors — atomic Put + godoc tightening + test c…
intel352 May 4, 2026
b1e8b69
Merge remote-tracking branch 'origin/main' into feat/iac-replace-foun…
intel352 May 4, 2026
daffe52
refactor(iac): ComputePlan signature accepts ctx+provider (no behavio…
intel352 May 4, 2026
b237337
feat(iac)!: wfctl infra plan now loads provider for Diff dispatch (BR…
intel352 May 4, 2026
7a1b863
feat(iac): wfctl infra apply threads provider into ComputePlan
intel352 May 4, 2026
e48fc8a
test(iac): update cross-package fakes for ComputePlan provider arg
intel352 May 4, 2026
09ce0b5
Merge remote-tracking branch 'origin/main' into feat/iac-replace-disp…
intel352 May 4, 2026
e7d2f7a
feat(iac): ComputePlan dispatches Diff per resource; emits replace ac…
intel352 May 4, 2026
446a0d1
perf(iac): ComputePlan consults diffcache before invoking provider.Diff
intel352 May 4, 2026
2f5e136
test(iac): T3.6e review — channel-gated parallel-dispatch in-flight t…
intel352 May 4, 2026
1e3d720
fix(iac): T3.6f review — pluginVersionKey uses sha256 instead of @ se…
intel352 May 4, 2026
92ff3d6
feat(iac): apply path branches on plugin manifest's iacProvider.compu…
intel352 May 4, 2026
97e0857
fix(iac): T3.7 review — drift report on partial failure + Path B cove…
intel352 May 4, 2026
40e07a1
fix(iac): map[string]bool drops gRPC args silently — sensitiveToAny c…
intel352 May 4, 2026
c9101ba
test(iac): T3.9 runtime-launch-validation via loader-seam (ADR 007)
intel352 May 4, 2026
d2e50d4
docs(pr): note bugs incidentally fixed by W-3b
intel352 May 4, 2026
32e54d9
docs(adr): amend ADR 007 with full T3.9 decision history (5 transitions)
intel352 May 4, 2026
9ef0a9c
fix(iac): T3.6e env-var hygiene — TestMain unsets WFCTL_PLAN_DIFF_CON…
intel352 May 4, 2026
1c3d16d
fix(iac): T3.6 polish — drop double error: prefix + reuse precomputed…
intel352 May 4, 2026
08fb8ed
fix(iac): T3.6/T3.9 polish — diff-cache bypass on empty ProviderID + …
intel352 May 4, 2026
de1c8cb
fix(iac): T3.6/T3.9 polish — preserve loadErr chain + lock-free diff …
intel352 May 4, 2026
7931e09
fix(iac): T3.7/T3.6 polish — DispatchVersionFor centralizes type asse…
intel352 May 4, 2026
951274d
docs(iac): T3.7 — correct DispatchVersionFor + findIaCPluginDir doc c…
intel352 May 4, 2026
a7627b5
test(iac): T3.5 — TestParseConcurrencyEnv subtest names (Copilot revi…
intel352 May 4, 2026
28d29c3
docs(iac): T3.5/T3.6 — clamp + in-flight counter doc accuracy (Copilo…
intel352 May 4, 2026
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **BREAKING (`wfctl infra plan`)**: configs declaring at least one `iac.provider` module now
require the plugin process to load successfully — `plan` invokes the same loader that `apply`
uses so `platform.ComputePlan` can dispatch `ResourceDriver.Diff` for honest Replace-action
classification (rev3 / W-3b T3.6b). On plugin-load failure `wfctl` exits non-zero and prints
`error: failed to load plugin "<name>": <reason>; wfctl infra plan now requires the plugin
process to compute Diff (since v0.21.0)` (the `error:` prefix is added by wfctl's top-level
printer; the underlying error returned from the command does not include it). There is no
`--no-provider` escape hatch
(rev3 YAGNI fix); operators who need pure offline validation should use `wfctl validate`.
Configs without any `iac.provider` module fall back to the legacy ConfigHash compare path so
minimal/legacy fixtures and out-of-band scripts continue to work.
- **`wfctl infra plan` empty-desired alignment with apply**: when the desired state is empty
(every resource removed from config) but providers are declared, `plan` now matches `apply`'s
behavior under the per-provider grouping logic: the loop over groupOrder is empty and no
delete actions are emitted. To preview what `apply` would tear down, use `wfctl infra destroy
--dry-run` (which exercises the same provider load + lists tracked state). Pre-W-3b plan
emitted Delete actions for every orphan in current state via the legacy single-call ComputePlan
path; this is consistent with the broader rev3 alignment between plan and apply.
- `driftInfraModules` uses `DriftClass` constants for output classification; drift-found message
updated to suggest `wfctl infra apply --refresh`.
- `DriftResult.Expected`, `DriftResult.Actual`, and `DriftResult.Fields` now carry `omitempty`
Expand Down
119 changes: 106 additions & 13 deletions cmd/wfctl/deploy_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,27 +76,52 @@ func newDeployProvider(provider string, wfCfg *config.WorkflowConfig, envName st
// they may return nil for the closer.
var resolveIaCProvider = discoverAndLoadIaCProvider

// iacPluginManifest is the minimal shape needed to read capabilities.iacProvider.name
// from a plugin.json without relying on the full PluginCapabilities struct.
// iacPluginManifest is the minimal shape needed to read both:
// - capabilities.iacProvider.name — used by findIaCPluginDir to
// match a plugin to a desired provider name; AND
// - iacProvider.computePlanVersion — used by W-3b T3.7 to decide
// between v1 (legacy provider.Apply) and v2
// (wfctlhelpers.ApplyPlan) dispatch at apply time.
//
// Both fields are unmarshaled from the same plugin.json bytes — no
// double parse — and either may be empty without affecting the
// other.
type iacPluginManifest struct {
Capabilities struct {
IaCProvider struct {
Name string `json:"name"`
} `json:"iacProvider"`
} `json:"capabilities"`
IaCProvider struct {
ComputePlanVersion string `json:"computePlanVersion"`
} `json:"iacProvider"`
}

// findIaCPluginDir scans pluginDir subdirectories for a plugin.json that
// declares capabilities.iacProvider.name == providerName.
// Returns ("", false, nil) when not found; ("name", true/false, nil) when the
// manifest matches (hasBinary indicates whether the executable is present).
func findIaCPluginDir(pluginDir, providerName string) (name string, hasBinary bool, err error) {
// Returns ("", "", false, nil) when not found; ("name", "computePlanVersion",
// true/false, nil) when the manifest matches (hasBinary indicates whether
// the executable is present).
//
// The computePlanVersion return is the RAW value from the SDK manifest's
// iacProvider.computePlanVersion field. This loader path performs only
// minimal json.Unmarshal — no schema validation — so callers must NOT
// assume the returned string is constrained to {"", "v1", "v2"}. Use
// the wfctlhelpers.DispatchVersionV2 constant for the v2-equality check
// (anything else, including unknown values and empty, defaults to v1):
//
// if computePlanVersion == wfctlhelpers.DispatchVersionV2 { ... v2 path ... }
//
// (wfctlhelpers.DispatchVersionFor takes a provider value, not a raw
// string, so it does not apply at this loader-level seam where only
// the string is in hand.)
func findIaCPluginDir(pluginDir, providerName string) (name, computePlanVersion string, hasBinary bool, err error) {
entries, err := os.ReadDir(pluginDir)
if err != nil {
if os.IsNotExist(err) {
return "", false, nil
return "", "", false, nil
}
return "", false, fmt.Errorf("scan plugin directory %q: %w", pluginDir, err)
return "", "", false, fmt.Errorf("scan plugin directory %q: %w", pluginDir, err)
}
for _, entry := range entries {
if !entry.IsDir() {
Expand All @@ -116,9 +141,9 @@ func findIaCPluginDir(pluginDir, providerName string) (name string, hasBinary bo
}
binaryPath := filepath.Join(pluginDir, pluginName, pluginName)
_, statErr := os.Stat(binaryPath)
return pluginName, statErr == nil, nil
return pluginName, m.IaCProvider.ComputePlanVersion, statErr == nil, nil
}
return "", false, nil
return "", "", false, nil
}

// loadIaCPlugin finds and loads the IaC plugin for the given provider, returning
Expand All @@ -127,7 +152,7 @@ func findIaCPluginDir(pluginDir, providerName string) (name string, hasBinary bo
var loadIaCPlugin = defaultLoadIaCPlugin

func defaultLoadIaCPlugin(pluginDir, providerName string) (pluginName string, factories map[string]plugin.ModuleFactory, closer io.Closer, err error) {
pName, hasBinary, findErr := findIaCPluginDir(pluginDir, providerName)
pName, _, hasBinary, findErr := findIaCPluginDir(pluginDir, providerName)
if findErr != nil {
return "", nil, nil, fmt.Errorf("resolve IaC provider %q: %w", providerName, findErr)
}
Expand All @@ -146,6 +171,25 @@ func defaultLoadIaCPlugin(pluginDir, providerName string) (pluginName string, fa
return pName, adapter.ModuleFactories(), closerFunc(func() error { mgr.Shutdown(); return nil }), nil
}

// readIaCPluginComputePlanVersion re-reads plugin.json under
// pluginDir to extract iacProvider.computePlanVersion for providerName.
// Returns "" (treated as v1 by the dispatcher) when the manifest
// can't be read or the field is omitted. Callers MUST tolerate empty
// — apply behavior degrades to the legacy v1 path on any read
// failure rather than blocking the apply.
//
// Re-reads rather than threading the version through loadIaCPlugin's
// existing 4-tuple return so the var-seam signature (and its 1 test
// override) stays stable. Cost: one extra os.ReadFile of a tiny JSON
// file per provider load — negligible vs. the gRPC plugin start.
func readIaCPluginComputePlanVersion(pluginDir, providerName string) string {
_, version, _, err := findIaCPluginDir(pluginDir, providerName)
if err != nil {
return ""
}
return version
}

// discoverAndLoadIaCProvider implements the default resolveIaCProvider: it scans
// the plugin directory for a plugin that declares iacProvider.name == providerName,
// loads it via ExternalPluginManager, and returns the IaCProvider plus a Closer
Expand Down Expand Up @@ -186,7 +230,10 @@ func discoverAndLoadIaCProvider(ctx context.Context, providerName string, cfg ma
return nil, nil, fmt.Errorf("plugin %q iac.provider module (%T) does not support service invocation — upgrade with: wfctl plugin update %s", pluginName, mod, pluginName)
}

iacProvider := &remoteIaCProvider{invoker: invoker}
iacProvider := &remoteIaCProvider{
invoker: invoker,
computePlanVersion: readIaCPluginComputePlanVersion(pluginDir, providerName),
}
// Notify the plugin that Initialize has been called (the plugin may treat
// this as a no-op if it already ran Initialize inside CreateModule).
if initErr := iacProvider.Initialize(ctx, cfg); initErr != nil {
Expand All @@ -209,8 +256,22 @@ type remoteServiceContextInvoker interface {
// remoteIaCProvider implements interfaces.IaCProvider by routing every method
// through InvokeService to the plugin subprocess. Only the methods needed by
// wfctl ci run deploy are fully implemented; the rest return a clear error.
//
// W-3b T3.7: also satisfies wfctlhelpers.ComputePlanVersionDeclarer via
// ComputePlanVersion(), populated from the plugin.json SDK manifest at
// load time. wfctl's apply path type-asserts the interface to choose
// between v1 (legacy provider.Apply) and v2 (wfctlhelpers.ApplyPlan)
// dispatch.
type remoteIaCProvider struct {
invoker remoteServiceInvoker
invoker remoteServiceInvoker
computePlanVersion string
}

// ComputePlanVersion satisfies wfctlhelpers.ComputePlanVersionDeclarer.
// Returns the SDK-manifest value cached at load time ("", "v1", or
// "v2"); empty defaults to v1 in the dispatcher.
func (r *remoteIaCProvider) ComputePlanVersion() string {
return r.computePlanVersion
}

func (r *remoteIaCProvider) Name() string {
Expand Down Expand Up @@ -472,6 +533,25 @@ type remoteResourceDriver struct {
resourceType string
}

// sensitiveToAny converts a map[string]bool (the Sensitive field on
// ResourceOutput) into the map[string]any shape structpb.NewStruct
// accepts. Returns nil for empty/nil input so the wire stays
// trim-friendly. Without this conversion, the upstream
// plugin/external/convert.go::mapToStruct silently drops the entire
// args struct on NewStruct failure (it returns &structpb.Struct{}
// rather than surfacing the typing error) — the bug T3.9
// runtime-launch-validation surfaced.
func sensitiveToAny(s map[string]bool) map[string]any {
if len(s) == 0 {
return nil
}
out := make(map[string]any, len(s))
for k, v := range s {
out[k] = v
}
return out
Comment thread
intel352 marked this conversation as resolved.
}

// wrapIaCError categorizes plugin errors by matching HTTP status codes and
// common message patterns, wrapping with the appropriate IaC sentinel so
// callers can use errors.Is for control flow. Errors crossing the plugin
Expand Down Expand Up @@ -650,7 +730,20 @@ func (d *remoteResourceDriver) Diff(_ context.Context, desired interfaces.Resour
"current_provider_id": current.ProviderID,
"current_status": current.Status,
"current_outputs": current.Outputs,
"current_sensitive": current.Sensitive,
}
// Sensitive crosses the gRPC boundary as map[string]any.
// structpb.NewStruct rejects map[string]bool; without this
// conversion the entire args struct silently drops to empty
// (mapToStruct in plugin/external/convert.go falls back to
// &structpb.Struct{} on err) and the plugin observes args=map[]
// — the bug T3.9 runtime-launch-validation surfaced.
//
// Only include the key when the converted map is non-empty, so the
// wire stays trim-friendly (matches sensitiveToAny's docstring).
// Setting `args["current_sensitive"] = nil` would serialize as a
// NullValue rather than omitting the field, defeating that intent.
if conv := sensitiveToAny(current.Sensitive); conv != nil {
args["current_sensitive"] = conv
}
res, err := d.invoker.InvokeService("ResourceDriver.Diff", args)
if err != nil {
Expand Down
60 changes: 60 additions & 0 deletions cmd/wfctl/deploy_providers_remote_driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,66 @@ func TestRemoteDriver_Diff(t *testing.T) {
}
}

// TestRemoteDriver_Diff_OmitsCurrentSensitiveWhenEmpty pins
// sensitiveToAny's "wire stays trim-friendly" docstring: when
// current.Sensitive is nil/empty, the "current_sensitive" arg is
// omitted entirely rather than serialized as null. (Setting
// args["current_sensitive"] = nil would round-trip through structpb
// as a NullValue, defeating the trim intent.)
func TestRemoteDriver_Diff_OmitsCurrentSensitiveWhenEmpty(t *testing.T) {
si := &stubInvoker{resp: map[string]any{"needs_update": false, "needs_replace": false}}
d := newDriver(si)
spec := sampleSpec()
current := &interfaces.ResourceOutput{
Name: "my-resource",
Type: "container_service",
ProviderID: "pid-123",
Outputs: map[string]any{},
Sensitive: nil, // explicit empty
}
if _, err := d.Diff(context.Background(), spec, current); err != nil {
t.Fatalf("Diff: %v", err)
}
if _, present := si.args["current_sensitive"]; present {
t.Errorf("current_sensitive arg present with empty Sensitive map; should be omitted (got %v)", si.args["current_sensitive"])
}
}

// TestRemoteDriver_Diff_IncludesCurrentSensitiveWhenPopulated is the
// positive complement: when Sensitive is non-empty, the converted
// map[string]any is sent across the wire so the plugin can observe
// per-key sensitivity flags (the round-trip that T3.9 runtime-launch-
// validation surfaced as silently dropped before sensitiveToAny existed).
func TestRemoteDriver_Diff_IncludesCurrentSensitiveWhenPopulated(t *testing.T) {
si := &stubInvoker{resp: map[string]any{"needs_update": false, "needs_replace": false}}
d := newDriver(si)
spec := sampleSpec()
current := &interfaces.ResourceOutput{
Name: "my-resource",
Type: "container_service",
ProviderID: "pid-123",
Outputs: map[string]any{},
Sensitive: map[string]bool{"password": true, "api_key": false},
}
if _, err := d.Diff(context.Background(), spec, current); err != nil {
t.Fatalf("Diff: %v", err)
}
v, ok := si.args["current_sensitive"]
if !ok {
t.Fatal("current_sensitive missing; expected populated map[string]any")
}
got, ok := v.(map[string]any)
if !ok {
t.Fatalf("current_sensitive type: got %T, want map[string]any", v)
}
if got["password"] != true {
t.Errorf("current_sensitive[password] = %v, want true", got["password"])
}
if got["api_key"] != false {
t.Errorf("current_sensitive[api_key] = %v, want false", got["api_key"])
}
}

// ── Scale ─────────────────────────────────────────────────────────────────────

func TestRemoteDriver_Scale(t *testing.T) {
Expand Down
9 changes: 8 additions & 1 deletion cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,14 @@ func runInfraPlan(args []string) error {
return fmt.Errorf("load current state: %w", err)
}

plan, err := platform.ComputePlan(desired, current)
// W-3b: load each iac.provider plugin and dispatch ComputePlan per
// provider group. The provider is required so platform.ComputePlan can
// invoke ResourceDriver.Diff for ForceNew-aware Replace classification
// (T3.6e). Configs without any iac.provider module fall back to a nil
// provider, which platform.ComputePlan tolerates with the legacy
// ConfigHash compare path (preserves minimal test fixtures and
// out-of-band scripts that never declared one).
plan, err := computePlanForInfraSpecs(context.Background(), cfgFile, envName, desired, current)
if err != nil {
return fmt.Errorf("compute plan: %w", err)
}
Expand Down
50 changes: 47 additions & 3 deletions cmd/wfctl/infra_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"github.com/GoCodeAlone/workflow/config"
"github.com/GoCodeAlone/workflow/iac/inputsnapshot"
"github.com/GoCodeAlone/workflow/iac/wfctlhelpers"
"github.com/GoCodeAlone/workflow/interfaces"
"github.com/GoCodeAlone/workflow/platform"
)
Expand Down Expand Up @@ -44,6 +45,19 @@ func printDriftReportIfAny(w io.Writer, result *interfaces.ApplyResult) {
// infra apply fails. Kept separate so tests can override it.
var infraApplyTroubleshootTimeout = 30 * time.Second

// computeInfraPlan is the indirection seam through which apply dispatches
// the diff plan. Defaults to platform.ComputePlan; tests override it to
// observe the provider arg without standing up a real gRPC plugin
// (mirroring resolveIaCProvider/loadIaCPlugin in deploy_providers.go).
var computeInfraPlan = platform.ComputePlan

// applyV2ApplyPlanFn is the indirection seam through which apply
// dispatches the v2 path (wfctlhelpers.ApplyPlan). Defaults to the
// production helper; tests override it to assert routing decisions
// without standing up a real plugin or executing real driver calls.
// Same var-seam pattern as computeInfraPlan / resolveIaCProvider.
var applyV2ApplyPlanFn = wfctlhelpers.ApplyPlan

// hasInfraModules reports whether cfgFile contains any modules with the new
// infra.* type prefix. Used by runInfraApply to select the dispatch path:
// direct IaCProvider path for infra.* configs, pipeline path for legacy
Expand Down Expand Up @@ -346,8 +360,13 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi
return err
}

// Compute the diff plan locally (provider-agnostic).
plan, err := platform.ComputePlan(specs, current)
// Compute the diff plan via the loaded provider so platform.ComputePlan
// can dispatch ResourceDriver.Diff over the live plugin process for
// honest Replace-action classification (T3.6e). Indirected through
// computeInfraPlan so tests can spy on the provider arg without
// standing up a real gRPC plugin (var-seam pattern matches
// resolveIaCProvider/loadIaCPlugin in deploy_providers.go).
plan, err := computeInfraPlan(ctx, provider, specs, current)
if err != nil {
return fmt.Errorf("compute plan: %w", err)
}
Expand All @@ -369,7 +388,32 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi
// we log and continue rather than blocking the apply.
validateInputProviderIDs(provider, &plan)
fmt.Printf(" Plan: %d action(s) to execute.\n", len(plan.Actions))
result, err := provider.Apply(ctx, &plan)

// W-3b T3.7: branch on the loaded plugin's manifest. Providers
// declaring iacProvider.computePlanVersion: v2 in plugin.json route
// through wfctlhelpers.ApplyPlan (Replace + drift postcondition);
// everything else takes the legacy provider.Apply path. NO env-var
// gate (rev2/rev3 fix per cycle-2 — there is no transitional
// WFCTL_USE_V2_APPLY); the choice is plugin-author-controlled at
// load time and surfaced via the optional
// wfctlhelpers.ComputePlanVersionDeclarer interface.
var result *interfaces.ApplyResult
if wfctlhelpers.DispatchVersionFor(provider) == wfctlhelpers.DispatchVersionV2 {
result, err = applyV2ApplyPlanFn(ctx, provider, &plan)
// printDriftReportIfAny was added unwired in W-3a/T3.1.5; the
// v2 dispatch is the production caller that surfaces input
// drift to the operator. Run on success OR partial failure
// (the operator most needs the drift diagnostic when an apply
// fails — "which input went stale during the failed apply?"
// — so we print whenever a result was produced rather than
// gating on err == nil). Silently no-ops when the report is
// empty, so unconditional-on-result-non-nil is safe.
if result != nil {
printDriftReportIfAny(w, result)
}
} else {
result, err = provider.Apply(ctx, &plan)
}
if err != nil {
// Derive the most specific resource ref we can for troubleshooting.
// Single-action plans give us an exact resource; multi-resource plans
Expand Down
8 changes: 7 additions & 1 deletion cmd/wfctl/infra_apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,13 @@ func (d *readDriver) Update(_ context.Context, ref interfaces.ResourceRef, spec
func (d *readDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { return nil }

func (d *readDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) {
return nil, nil
// W-3b ComputePlan dispatches Diff per resource. The adoption tests
// expect the post-adoption ComputePlan to emit "update" because
// desired and adopted-live configs diverge; pre-W-3b that was the
// ConfigHash compare's job, post-W-3b a real driver would return
// NeedsUpdate=true. Modeling that here keeps the adoption-flow
// assertions tight without each test reconstructing the diff.
return &interfaces.DiffResult{NeedsUpdate: true}, nil
}

func (d *readDriver) HealthCheck(_ context.Context, _ interfaces.ResourceRef) (*interfaces.HealthResult, error) {
Expand Down
Loading
Loading