Skip to content
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Plan-time JIT resolver**: `wfctl infra plan` and `wfctl infra apply` now resolve
`${MODULE.field}` and `infra_output`-typed `${SECRET}` references against existing state outputs
before computing the diff plan. Eliminates spurious `replace` actions caused by drivers (e.g.
`DropletDriver.Diff`) comparing template literals against real state values. Refs whose source
module is not yet in state stay templated for apply-time JIT (existing W-5 path). See ADR 0013.
- **`jitsubst.TryResolveSpec`**: lenient sibling of `ResolveSpec` for plan-time use. Substitutes
resolvable refs and leaves unresolved refs verbatim (with diagnostics). Strict `ResolveSpec`
semantics and error contract unchanged.

- **`wfctl infra bootstrap --force-rotate <name>`** flag for known-bad secret recovery. Repeatable
flag; also accepts comma-separated values (e.g. `--force-rotate FOO,BAR --force-rotate BAZ`).
Validates each name against `secrets.generate[]` before touching the store (fast-fail on typos),
Expand Down
46 changes: 46 additions & 0 deletions cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,23 @@ func runInfraPlan(args []string) error {
desired = filterSpecsByInclude(desired, planIncludeSet)
current = filterStatesByInclude(current, planIncludeSet)

// Plan-time JIT resolution (PR-1): substitute ${MODULE.field} and
// ${SECRET} refs against current state so driver.Diff sees real
// values instead of literal templates. Refs whose source isn't in
// state stay templated; planRequiresJITSubstitution detects them
// and SchemaVersion=2 stamping handles the apply-time path.
Comment on lines 258 to +265
var resolutionDiags []ResolutionDiagnostic
{
wfCfgForResolver, cfgLoadErr := config.LoadFromFile(cfgFile)
if cfgLoadErr != nil {
return fmt.Errorf("load config for plan-time resolver: %w", cfgLoadErr)
}
desired, resolutionDiags, err = resolveSpecsAgainstState(desired, current, wfCfgForResolver, envName)
if err != nil {
return fmt.Errorf("resolve specs against state: %w", err)
}
Comment on lines +268 to +275
}

// 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
Expand Down Expand Up @@ -299,6 +316,16 @@ func runInfraPlan(args []string) error {
fmt.Print(formatPlanTable(plan, showSensitive))
}

// Print any plan-time JIT resolution diagnostics after the plan table
// so they're visible only when the plan rendering succeeded.
if len(resolutionDiags) > 0 {
fmt.Println()
fmt.Println("Pending JIT resolution (apply-time):")
for _, d := range resolutionDiags {
fmt.Printf(" %s: ${%s}\n", d.ResourceName, d.Ref)
}
}

if *output != "" {
// T5.5: persisted plan.json is the wfctl-infra-apply --plan
// canonical input. JIT-style plans cannot be persisted because
Expand Down Expand Up @@ -1318,6 +1345,25 @@ func runInfraApply(args []string) error {
return inputsnapshot.NewStaleError(drift)
}
}
// Mirror the plan-time resolver: apply resolveSpecsAgainstState before
// hashing so that DesiredHash is computed on post-resolution specs, matching
// what runInfraPlan recorded in plan.DesiredHash. Without this step, any ref
// that resolved at plan time would cause a currentHash != plan.DesiredHash
// mismatch on every --plan apply.
{
currentState, stateErr := loadCurrentState(cfgFile, envName)
if stateErr != nil {
return fmt.Errorf("load state for stale-check: %w", stateErr)
}
planApplyCfg, cfgErr := config.LoadFromFile(cfgFile)
if cfgErr != nil {
return fmt.Errorf("load config for stale-check: %w", cfgErr)
}
desired, _, err = resolveSpecsAgainstState(desired, currentState, planApplyCfg, envName)
if err != nil {
return fmt.Errorf("resolve specs for stale-check: %w", err)
}
}
currentHash := desiredStateHash(desired)
if plan.DesiredHash != currentHash {
return fmt.Errorf("plan stale: config hash mismatch (run wfctl infra plan again)")
Expand Down
9 changes: 9 additions & 0 deletions cmd/wfctl/infra_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,15 @@ func applyInfraModules(ctx context.Context, cfgFile, envName string) error { //n
return fmt.Errorf("load config: %w", err)
}

// Plan-time JIT resolution (PR-1): substitute ${MODULE.field} and
// ${SECRET} refs against current state so driver.Diff sees real
// values instead of literal templates. Apply does not print the
// diagnostics — they're plan-output sugar only.
infraSpecs, _, err = resolveSpecsAgainstState(infraSpecs, current, cfg, envName)
if err != nil {
return fmt.Errorf("resolve specs against state: %w", err)
}

// Build a lookup table of iac.provider module name → (providerType, providerCfg).
// Also track which providers are explicitly disabled for this env so we can
// emit a precise error if an infra module references one.
Expand Down
176 changes: 176 additions & 0 deletions cmd/wfctl/infra_apply_resolve_state_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package main

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"testing"
"time"

"github.com/GoCodeAlone/workflow/interfaces"
)

// writeApplyStateFile creates a state JSON file for the apply tests.
// Duplicated here rather than shared because test helpers are package-scoped
// and we want to keep each test file self-contained.
func writeApplyStateFile(t *testing.T, dir, name, resourceType, providerID string, outputs map[string]any, cfg map[string]any) {
t.Helper()
rec := iacStateRecord{
ResourceID: name,
ResourceType: resourceType,
Provider: "test-cloud",
ProviderID: providerID,
Status: "applied",
Config: cfg,
Outputs: outputs,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
}
data, err := json.MarshalIndent(rec, "", " ")
if err != nil {
t.Fatalf("marshal state for %q: %v", name, err)
}
fname := filepath.Join(dir, sanitizeStateID(name)+".json")
if err := os.WriteFile(fname, data, 0o600); err != nil {
t.Fatalf("write state file for %q: %v", name, err)
}
}

// TestRunInfraApply_ResolvesSpecsBeforeComputePlan is the TC2 regression test
// for the apply path: when vpc_uuid is an infra_output secret pointing at
// core-dump-vpc.id, apply should resolve it to the real ID before computing
// the diff plan — meaning the droplet should see no change and NOT be replaced.
//
// The test uses secrets.generate (type: infra_output) and does NOT inject the
// env var via t.Setenv, so the only resolution path is the state-output resolver.
// This ensures the test fails if resolveSpecsAgainstState's infra_output handling
// is broken (rather than succeeding vacuously via os.LookupEnv).
func TestRunInfraApply_ResolvesSpecsBeforeComputePlan(t *testing.T) {
dir := t.TempDir()
stateDir := filepath.Join(dir, "state")
if err := os.MkdirAll(stateDir, 0o750); err != nil {
t.Fatalf("mkdir state: %v", err)
}

const vpcID = "14badc41-1234-5678-abcd-ef0123456789"
writeApplyStateFile(t, stateDir, "core-dump-vpc", "infra.vpc", vpcID,
map[string]any{"id": vpcID, "cidr": "10.0.0.0/16"},
map[string]any{"provider": "do-provider", "cidr": "10.0.0.0/16"})
writeApplyStateFile(t, stateDir, "coredump-staging-pg", "infra.droplet", "droplet-99",
map[string]any{"vpc_uuid": vpcID},
map[string]any{"provider": "do-provider", "vpc_uuid": vpcID, "size": "s-1vcpu-2gb"})
Comment on lines +58 to +64

cfgPath := filepath.Join(dir, "infra.yaml")
if err := os.WriteFile(cfgPath, []byte(`
infra:
auto_bootstrap: false

modules:
- name: do-provider
type: iac.provider
config:
provider: test-cloud

- name: do-state
type: iac.state
config:
backend: filesystem
directory: `+stateDir+`

- name: core-dump-vpc
type: infra.vpc
config:
provider: do-provider
cidr: "10.0.0.0/16"

- name: coredump-staging-pg
type: infra.droplet
config:
provider: do-provider
vpc_uuid: "${STAGING_VPC_UUID}"
size: s-1vcpu-2gb

secrets:
provider: env
generate:
- key: STAGING_VPC_UUID
type: infra_output
source: core-dump-vpc.id
`), 0o600); err != nil {
Comment on lines +58 to +102
t.Fatalf("write config: %v", err)
}

// Track what actions computeInfraPlan receives.
var capturedActions []interfaces.PlanAction

// Override computeInfraPlan with TC2 field-compare logic. This is the
// load-bearing seam for testing pre-plan resolution: before PR-1,
// spec.Config["vpc_uuid"] == "${STAGING_VPC_UUID}" (literal template)
// which differs from the stored vpc_uuid → replace fires.
// After PR-1, resolveSpecsAgainstState collapses it to the real UUID
// before this function is called → no diff → no replace.
origCompute := computeInfraPlan
computeInfraPlan = func(_ context.Context, _ interfaces.IaCProvider, desired []interfaces.ResourceSpec, current []interfaces.ResourceState) (interfaces.IaCPlan, error) {
currentMap := make(map[string]interfaces.ResourceState, len(current))
for _, s := range current {
currentMap[s.Name] = s
}
var actions []interfaces.PlanAction
for _, spec := range desired {
rs, exists := currentMap[spec.Name]
if !exists {
actions = append(actions, interfaces.PlanAction{Action: "create", Resource: spec})
continue
}
anyDiff := false
for k, desiredVal := range spec.Config {
if k == "provider" {
continue
}
stateVal, hasField := rs.AppliedConfig[k]
if !hasField {
stateVal, hasField = rs.Outputs[k]
}
sv := ""
if hasField {
sv = fmt.Sprintf("%v", stateVal)
}
if !hasField || fmt.Sprintf("%v", desiredVal) != sv {
anyDiff = true
break
}
}
if anyDiff {
actions = append(actions, interfaces.PlanAction{
Action: "replace",
Resource: spec,
Current: &rs,
})
}
}
capturedActions = actions
return interfaces.IaCPlan{Actions: actions}, nil
}
t.Cleanup(func() { computeInfraPlan = origCompute })

// Override resolveIaCProvider to avoid real plugin load.
origResolve := resolveIaCProvider
resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) {
return &applyCapture{}, nil, nil
}
t.Cleanup(func() { resolveIaCProvider = origResolve })

if err := runInfraApply([]string{"--config", cfgPath, "--auto-approve"}); err != nil {
t.Fatalf("runInfraApply: %v", err)
}

// Key assertion: no spurious replace on coredump-staging-pg.
for _, a := range capturedActions {
if a.Action == "replace" && a.Resource.Name == "coredump-staging-pg" {
t.Errorf("spurious replace fired on coredump-staging-pg — plan-time resolution did not collapse ${STAGING_VPC_UUID}")
}
}
}
Loading
Loading