From 324d85684012b66e5029506db4aa2cd0873bb5b3 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 19:05:05 -0400 Subject: [PATCH 01/38] feat(iac): add IaCPlan.SchemaVersion + InputSnapshot + PlanAction.ResolvedConfigHash + DriftEntry type --- interfaces/iac_state.go | 22 ++++++++++++++++ interfaces/iac_state_test.go | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 interfaces/iac_state_test.go diff --git a/interfaces/iac_state.go b/interfaces/iac_state.go index d3d87d930..1b7a2739a 100644 --- a/interfaces/iac_state.go +++ b/interfaces/iac_state.go @@ -51,6 +51,14 @@ type IaCPlan struct { // (sorted ResourceSpecs) at the time the plan was generated. wfctl infra apply // --plan compares this against the current config to detect stale plans. DesiredHash string `json:"plan_hash,omitempty"` + + // SchemaVersion is bumped when on-disk plan format changes (W-5 sets to 2 when JIT is required). + SchemaVersion int `json:"schema_version,omitempty"` + + // InputSnapshot records every env var name read during ${VAR} substitution + // at plan time, mapped to a 16-hex-char (64-bit) sha256 prefix of the value. + // Apply re-computes inputs and prints diagnostic on mismatch. + InputSnapshot map[string]string `json:"input_snapshot,omitempty"` } // PlanAction is a single planned change within an IaCPlan. @@ -59,6 +67,20 @@ type PlanAction struct { Resource ResourceSpec `json:"resource"` Current *ResourceState `json:"current,omitempty"` Changes []FieldChange `json:"changes,omitempty"` + + // ResolvedConfigHash is the SHA-256 of POST-substitution Resource.Config. + // Apply re-computes per-action and surfaces per-resource diagnostic on mismatch. + ResolvedConfigHash string `json:"resolved_config_hash,omitempty"` +} + +// DriftEntry names a single env-var whose fingerprint changed between plan-time +// and apply-time. Used by both the persisted-`--plan` path (cmd/wfctl/infra.go, +// wired in T1.5) and the in-process apply path (wfctlhelpers.ApplyPlan, wired +// in T3.1.5 — both via inputsnapshot.FormatStaleError). +type DriftEntry struct { + Name string `json:"name"` + PlanFingerprint string `json:"plan_fingerprint"` + ApplyFingerprint string `json:"apply_fingerprint"` } // ApplyResult summarises the outcome of applying a plan. diff --git a/interfaces/iac_state_test.go b/interfaces/iac_state_test.go new file mode 100644 index 000000000..029196af1 --- /dev/null +++ b/interfaces/iac_state_test.go @@ -0,0 +1,51 @@ +package interfaces + +import ( + "encoding/json" + "testing" +) + +func TestIaCPlan_SchemaVersionField(t *testing.T) { + p := IaCPlan{SchemaVersion: 2} + data, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + var got IaCPlan + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.SchemaVersion != 2 { + t.Errorf("SchemaVersion roundtrip: got %d want 2", got.SchemaVersion) + } +} + +func TestIaCPlan_InputSnapshotField(t *testing.T) { + p := IaCPlan{InputSnapshot: map[string]string{"FOO": "deadbeefcafebabe"}} + data, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + var got IaCPlan + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.InputSnapshot["FOO"] != "deadbeefcafebabe" { + t.Errorf("InputSnapshot roundtrip failed: %v", got.InputSnapshot) + } +} + +func TestPlanAction_ResolvedConfigHashField(t *testing.T) { + a := PlanAction{Action: "create", ResolvedConfigHash: "sha256:abc"} + data, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + var got PlanAction + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.ResolvedConfigHash != "sha256:abc" { + t.Errorf("ResolvedConfigHash: got %q", got.ResolvedConfigHash) + } +} From 7c8c3ac1d4c8ee454454604db9c6efce3163ad52 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 19:06:20 -0400 Subject: [PATCH 02/38] feat(iac): add inputsnapshot.Compute + Snapshot + NewTolerantEnvProvider with preservation sentinel --- iac/inputsnapshot/snapshot.go | 80 ++++++++++++++++++++++++++++++ iac/inputsnapshot/snapshot_test.go | 62 +++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 iac/inputsnapshot/snapshot.go create mode 100644 iac/inputsnapshot/snapshot_test.go diff --git a/iac/inputsnapshot/snapshot.go b/iac/inputsnapshot/snapshot.go new file mode 100644 index 000000000..7a49a54f5 --- /dev/null +++ b/iac/inputsnapshot/snapshot.go @@ -0,0 +1,80 @@ +// Package inputsnapshot computes plan-time env-var fingerprints for the +// plan-stale diagnostic. Fingerprints are 16 hex chars (64 bits of preimage +// resistance); plan.json is treated as semi-sensitive and gitignored. +package inputsnapshot + +import ( + "crypto/sha256" + "encoding/hex" + "os" +) + +// Compute returns a map of env-var name → 16-hex-char sha256 prefix of the value. +// Variables that aren't set (lookup returns ok=false) are omitted from the snapshot. +func Compute(varNames []string, lookup func(string) (string, bool)) map[string]string { + out := make(map[string]string) + for _, name := range varNames { + val, ok := lookup(name) + if !ok { + continue + } + if val == preservedFingerprint { + // Sentinel from NewTolerantEnvProvider — pass through unhashed + // so ComputeDrift recognizes the preservation signal. (rev6 — + // unexported per cycle-5; in-package access only.) + out[name] = preservedFingerprint + continue + } + sum := sha256.Sum256([]byte(val)) + out[name] = hex.EncodeToString(sum[:])[:16] + } + return out +} + +// Snapshot is an alias for Compute that reads slightly more naturally at +// the in-process apply postcondition call site (T3.1.5). +func Snapshot(names []string, provider func(string) (string, bool)) map[string]string { + return Compute(names, provider) +} + +// OSEnvProvider is the canonical env-provider closure that reads from +// process env via os.LookupEnv. Used by start-of-apply InputSnapshot capture. +func OSEnvProvider(name string) (string, bool) { return os.LookupEnv(name) } + +// preservedFingerprint is a sentinel value indicating an env-var was set at +// plan time but is unset at apply time (sub-action cleanup is the canonical +// case). ComputeDrift (T1.5) skips drift detection for keys whose applySnap +// value is this sentinel. UNEXPORTED (rev6 — addresses cycle-5 Important on +// external-bypass channel): NewTolerantEnvProvider is the only sanctioned +// way to inject the sentinel; external callers cannot defeat drift detection. +// +// Cross-function contract: +// - Compute (this file, in-package) passes the sentinel through unhashed. +// - NewTolerantEnvProvider (this file) returns the sentinel for plan-time-set +// but apply-time-unset vars (in-package access to the constant). +// - ComputeDrift (compute_drift.go, T1.5, same package) honors the sentinel +// by skipping drift detection for that key. +const preservedFingerprint = "__plan_time_preserved__" + +// NewTolerantEnvProvider returns an EnvProvider closure used by the +// in-process apply postcondition (T3.1.5). When a var was set at plan time +// (present in planSnapshot) but is now unset (sub-action cleanup), the +// closure returns the in-package preservedFingerprint sentinel so +// ComputeDrift suppresses the (false-positive) drift entry. For vars +// genuinely unset at both times, returns ("", false) → Compute drops the +// key from the resulting map. +// +// This is the ONLY sanctioned way to inject the preservation sentinel. +// Direct callers of Compute with a custom env-provider cannot construct +// the sentinel value because it is unexported. +func NewTolerantEnvProvider(planSnapshot map[string]string) func(name string) (string, bool) { + return func(name string) (string, bool) { + if val, ok := os.LookupEnv(name); ok { + return val, true + } + if _, wasInPlan := planSnapshot[name]; wasInPlan { + return preservedFingerprint, true + } + return "", false + } +} diff --git a/iac/inputsnapshot/snapshot_test.go b/iac/inputsnapshot/snapshot_test.go new file mode 100644 index 000000000..b946086ec --- /dev/null +++ b/iac/inputsnapshot/snapshot_test.go @@ -0,0 +1,62 @@ +package inputsnapshot + +import ( + "os" + "testing" +) + +func TestCompute_FingerprintIs16HexChars(t *testing.T) { + snap := Compute([]string{"FOO"}, func(k string) (string, bool) { + return "the-value", true + }) + if got := snap["FOO"]; len(got) != 16 { + t.Errorf("fingerprint len = %d, want 16; got %q", len(got), got) + } +} + +func TestCompute_DeterministicAcrossRuns(t *testing.T) { + env := func(k string) (string, bool) { return "v", true } + a := Compute([]string{"FOO"}, env) + b := Compute([]string{"FOO"}, env) + if a["FOO"] != b["FOO"] { + t.Errorf("non-deterministic: %q vs %q", a["FOO"], b["FOO"]) + } +} + +func TestCompute_DifferentValuesDifferentFingerprints(t *testing.T) { + env1 := func(k string) (string, bool) { return "value-one", true } + env2 := func(k string) (string, bool) { return "value-two", true } + a := Compute([]string{"FOO"}, env1) + b := Compute([]string{"FOO"}, env2) + if a["FOO"] == b["FOO"] { + t.Errorf("fingerprints should differ: %q == %q", a["FOO"], b["FOO"]) + } +} + +func TestCompute_MissingEnvVarOmitted(t *testing.T) { + snap := Compute([]string{"NOT_SET"}, func(k string) (string, bool) { + return "", false + }) + if _, ok := snap["NOT_SET"]; ok { + t.Errorf("missing env should be omitted, got %q", snap["NOT_SET"]) + } +} + +func TestNewTolerantEnvProvider_UnsetButPlanned_ReturnsSentinel(t *testing.T) { + os.Unsetenv("STAGING_PG_PASSWORD") + plan := map[string]string{"STAGING_PG_PASSWORD": "deadbeef00000000"} + provider := NewTolerantEnvProvider(plan) + val, ok := provider("STAGING_PG_PASSWORD") + if !ok || val != preservedFingerprint { + t.Errorf("expected (preservedFingerprint, true) for plan-time-set unset-now var; got (%q, %v)", val, ok) + } +} + +func TestCompute_PreservesSentinel(t *testing.T) { + snap := Compute([]string{"FOO"}, func(name string) (string, bool) { + return preservedFingerprint, true + }) + if snap["FOO"] != preservedFingerprint { + t.Errorf("Compute should pass sentinel through unhashed; got %q", snap["FOO"]) + } +} From 4774c3b7de0568c723b27a45204091f7cca54e7c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 19:11:17 -0400 Subject: [PATCH 03/38] feat(iac): wfctl infra plan writes InputSnapshot to plan.json --- cmd/wfctl/infra.go | 11 +++ cmd/wfctl/infra_inputsnapshot.go | 90 ++++++++++++++++++++++ cmd/wfctl/infra_plan_inputsnapshot_test.go | 49 ++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 cmd/wfctl/infra_inputsnapshot.go create mode 100644 cmd/wfctl/infra_plan_inputsnapshot_test.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index f4fc129fd..9e6b033e3 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -201,6 +201,17 @@ func runInfraPlan(args []string) error { return fmt.Errorf("compute plan: %w", err) } + // Capture env-var fingerprints so apply (persisted-plan path: T1.5; in-process + // path: T3.1.5) can surface a per-key diagnostic when a referenced env var + // changed between plan and apply. Bumped to schema version 1 so older + // readers that predate this field can be detected and rejected. + snap, err := computeInfraInputSnapshot(cfgFile, envName) + if err != nil { + return fmt.Errorf("compute input snapshot: %w", err) + } + plan.InputSnapshot = snap + plan.SchemaVersion = 1 + switch *format { case "markdown": fmt.Print(formatPlanMarkdown(plan, showSensitive)) diff --git a/cmd/wfctl/infra_inputsnapshot.go b/cmd/wfctl/infra_inputsnapshot.go new file mode 100644 index 000000000..b94ba7d27 --- /dev/null +++ b/cmd/wfctl/infra_inputsnapshot.go @@ -0,0 +1,90 @@ +package main + +import ( + "os" + "sort" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/inputsnapshot" +) + +// collectInfraEnvVarRefs returns a sorted, de-duplicated list of env-var +// names referenced via ${VAR} or $VAR in the raw (pre-substitution) Configs +// of all infra.* and platform.* modules in cfgFile. +// +// When envName is non-empty, per-environment overrides are applied via +// ModuleConfig.ResolveForEnv before scanning, so env-specific substitution +// references are captured. +// +// Preserved-key submaps (env_vars / env_vars_secret / secret_env_vars) are +// scanned just like any other map: their ${VAR} literals are kept verbatim +// in the persisted plan but the plan-time fingerprint of the underlying env +// var is still recorded so apply-time drift is detectable. +func collectInfraEnvVarRefs(cfgFile, envName string) ([]string, error) { + cfg, err := config.LoadFromFile(cfgFile) + if err != nil { + return nil, err + } + seen := map[string]struct{}{} + record := func(name string) string { + if name != "" { + seen[name] = struct{}{} + } + return "" + } + for i := range cfg.Modules { + m := &cfg.Modules[i] + if !isInfraType(m.Type) { + continue + } + if envName == "" { + walkValueForEnvRefs(m.Config, record) + continue + } + resolved, ok := m.ResolveForEnv(envName) + if !ok { + continue + } + walkValueForEnvRefs(resolved.Config, record) + } + names := make([]string, 0, len(seen)) + for k := range seen { + names = append(names, k) + } + sort.Strings(names) + return names, nil +} + +// walkValueForEnvRefs recursively scans v for ${VAR} / $VAR references in +// any string values, calling record(name) for each. Maps and slices are +// walked element-wise; non-string scalars are ignored. +func walkValueForEnvRefs(v any, record func(string) string) { + switch val := v.(type) { + case string: + // os.Expand walks ${VAR} and $VAR references the same way os.ExpandEnv + // does at substitution time, so the name set captured here matches the + // set that ExpandEnvInMap[PreservingKeys] would actually substitute. + os.Expand(val, record) + case map[string]any: + for _, vv := range val { + walkValueForEnvRefs(vv, record) + } + case []any: + for _, vv := range val { + walkValueForEnvRefs(vv, record) + } + } +} + +// computeInfraInputSnapshot returns the env-var fingerprint map for cfgFile's +// infra/platform modules. Returns (nil, nil) when no ${VAR} references exist. +func computeInfraInputSnapshot(cfgFile, envName string) (map[string]string, error) { + names, err := collectInfraEnvVarRefs(cfgFile, envName) + if err != nil { + return nil, err + } + if len(names) == 0 { + return nil, nil + } + return inputsnapshot.Compute(names, inputsnapshot.OSEnvProvider), nil +} diff --git a/cmd/wfctl/infra_plan_inputsnapshot_test.go b/cmd/wfctl/infra_plan_inputsnapshot_test.go new file mode 100644 index 000000000..a6f7d9d3f --- /dev/null +++ b/cmd/wfctl/infra_plan_inputsnapshot_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +func TestPlanWritesInputSnapshot(t *testing.T) { + t.Setenv("STAGING_DB_PASSWORD", "secret-value") + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + if err := os.WriteFile(cfgPath, []byte(` +modules: + - name: app + type: infra.container_service + config: + env_vars: + DATABASE_URL: "postgres://user:${STAGING_DB_PASSWORD}@host:5432/db" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + planFile := filepath.Join(dir, "plan.json") + + if err := runInfraPlan([]string{"--config", cfgPath, "--output", planFile}); err != nil { + t.Fatalf("runInfraPlan: %v", err) + } + + data, err := os.ReadFile(planFile) + if err != nil { + t.Fatalf("read plan: %v", err) + } + var plan interfaces.IaCPlan + if err := json.Unmarshal(data, &plan); err != nil { + t.Fatalf("unmarshal plan: %v", err) + } + if plan.InputSnapshot["STAGING_DB_PASSWORD"] == "" { + t.Errorf("plan.InputSnapshot missing STAGING_DB_PASSWORD; got %v", plan.InputSnapshot) + } + if got := plan.InputSnapshot["STAGING_DB_PASSWORD"]; len(got) != 16 { + t.Errorf("fingerprint should be 16 hex chars, got %d (%q)", len(got), got) + } + if plan.SchemaVersion != 1 { + t.Errorf("SchemaVersion = %d, want 1", plan.SchemaVersion) + } +} From 295d354c301501b3da718029389a1a52418a4424 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 19:12:58 -0400 Subject: [PATCH 04/38] feat(iac): ComputePlan sets PlanAction.ResolvedConfigHash --- platform/differ.go | 12 +++++++----- platform/differ_test.go | 43 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/platform/differ.go b/platform/differ.go index dfc1d81e3..f79a75390 100644 --- a/platform/differ.go +++ b/platform/differ.go @@ -36,15 +36,17 @@ func ComputePlan(desired []interfaces.ResourceSpec, current []interfaces.Resourc hash := configHash(spec.Config) if rs, exists := currentMap[spec.Name]; !exists { creates = append(creates, interfaces.PlanAction{ - Action: "create", - Resource: spec, + Action: "create", + Resource: spec, + ResolvedConfigHash: hash, }) } else if rs.ConfigHash != hash { rsCopy := rs updates = append(updates, interfaces.PlanAction{ - Action: "update", - Resource: spec, - Current: &rsCopy, + Action: "update", + Resource: spec, + Current: &rsCopy, + ResolvedConfigHash: hash, }) } // No change: skip. diff --git a/platform/differ_test.go b/platform/differ_test.go index 0d733f23d..59024fc44 100644 --- a/platform/differ_test.go +++ b/platform/differ_test.go @@ -206,3 +206,46 @@ func TestDiffer_CycleDetection(t *testing.T) { t.Errorf("error = %q, expected 'cycle' in message", err.Error()) } } + +func TestComputePlan_PerActionResolvedConfigHash(t *testing.T) { + desired := []interfaces.ResourceSpec{ + {Name: "vpc", Type: "infra.vpc", Config: map[string]any{"region": "nyc1"}}, + } + plan, err := platform.ComputePlan(desired, nil) + if err != nil { + t.Fatal(err) + } + if len(plan.Actions) != 1 { + t.Fatalf("expected 1 action, got %d", len(plan.Actions)) + } + if plan.Actions[0].ResolvedConfigHash == "" { + t.Errorf("expected ResolvedConfigHash on create action, got %+v", plan.Actions[0]) + } + want := platform.ConfigHash(desired[0].Config) + if got := plan.Actions[0].ResolvedConfigHash; got != want { + t.Errorf("ResolvedConfigHash = %q, want %q", got, want) + } +} + +func TestComputePlan_ResolvedConfigHashOnUpdate(t *testing.T) { + desired := []interfaces.ResourceSpec{ + {Name: "db", Type: "infra.database", Config: map[string]any{"engine": "postgres", "size": "db-s"}}, + } + current := []interfaces.ResourceState{ + {Name: "db", Type: "infra.database", ConfigHash: "stale-hash"}, + } + plan, err := platform.ComputePlan(desired, current) + if err != nil { + t.Fatal(err) + } + if len(plan.Actions) != 1 || plan.Actions[0].Action != "update" { + t.Fatalf("expected 1 update action, got %+v", plan.Actions) + } + if plan.Actions[0].ResolvedConfigHash == "" { + t.Errorf("expected ResolvedConfigHash on update action, got %+v", plan.Actions[0]) + } + want := platform.ConfigHash(desired[0].Config) + if got := plan.Actions[0].ResolvedConfigHash; got != want { + t.Errorf("ResolvedConfigHash = %q, want %q", got, want) + } +} From b442dae9cac9cd4c68fa1c5409735fc6f66d69b4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 19:15:35 -0400 Subject: [PATCH 05/38] feat(iac): wfctl infra plan warns when plan.json not in .gitignore --- cmd/wfctl/infra.go | 5 ++ cmd/wfctl/infra_plan_gitignore.go | 96 +++++++++++++++++++++++++ cmd/wfctl/infra_plan_gitignore_test.go | 97 ++++++++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 cmd/wfctl/infra_plan_gitignore.go create mode 100644 cmd/wfctl/infra_plan_gitignore_test.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 9e6b033e3..c5e7161de 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -228,6 +228,11 @@ func runInfraPlan(args []string) error { return fmt.Errorf("write plan: %w", err) } fmt.Printf("\nPlan saved to %s\n", *output) + // Plan files carry semi-sensitive content (env-var fingerprints, + // resolved configs); warn the operator when none of the reachable + // .gitignore files cover the output path. Silent when the directory + // is not under a tracked repo (no .gitignore present). + warnIfPlanNotGitignored(os.Stderr, *output) } return nil diff --git a/cmd/wfctl/infra_plan_gitignore.go b/cmd/wfctl/infra_plan_gitignore.go new file mode 100644 index 000000000..48682b8e2 --- /dev/null +++ b/cmd/wfctl/infra_plan_gitignore.go @@ -0,0 +1,96 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// warnIfPlanNotGitignored writes a stderr warning to w when planPath is not +// covered by any .gitignore reachable from the directory containing planPath +// up to the filesystem root. +// +// Why: plan.json carries semi-sensitive content (env-var fingerprints, +// resolved configs, sometimes provider IDs); committing it to source control +// is almost always accidental. We don't promise full gitignore semantics — +// the heuristic catches the common cases (literal basename, simple +// extension/path globs) and stays silent when no .gitignore exists at all +// (likely not a tracked repo). +// +// No warning is emitted when: +// - No .gitignore is found between planDir and the filesystem root. +// - At least one reachable .gitignore contains a line matching the plan's +// basename, the literal plan path, "*.json", "*", or a "**/" pattern +// ending with the basename. +func warnIfPlanNotGitignored(w io.Writer, planPath string) { + abs, err := filepath.Abs(planPath) + if err != nil { + return + } + base := filepath.Base(abs) + dir := filepath.Dir(abs) + + foundAny := false + covered := false + for { + gitignore := filepath.Join(dir, ".gitignore") + if data, err := os.ReadFile(gitignore); err == nil { + foundAny = true + if gitignoreCovers(data, base, abs, dir) { + covered = true + break + } + } + parent := filepath.Dir(dir) + if parent == dir { + break // reached filesystem root + } + dir = parent + } + if foundAny && !covered { + fmt.Fprintf(w, "warning: %s is not covered by .gitignore — plan.json may contain semi-sensitive data; add %q to .gitignore before committing.\n", planPath, base) + } +} + +// gitignoreCovers performs a pragmatic match against a .gitignore content for +// patterns that would exclude planAbs (basename = base, found at gitignoreDir). +// This is intentionally a heuristic, not full gitignore semantics: it covers +// the common cases (literal basename, "*.ext", "**/", and a path +// relative to the gitignore directory) and ignores negation rules. +func gitignoreCovers(data []byte, base, planAbs, gitignoreDir string) bool { + ext := filepath.Ext(base) + scanner := bufio.NewScanner(strings.NewReader(string(data))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "!") { + continue // negation rules — skip; conservative (warn even if a later rule re-includes) + } + // Strip a leading "/" — gitignore-relative anchor; we treat both + // "/foo" and "foo" as candidates against the basename or relative path. + anchored := strings.TrimPrefix(line, "/") + + if anchored == base { + return true + } + if ext != "" && (anchored == "*"+ext || anchored == "**/*"+ext) { + return true + } + // "**/" matches at any depth. + if anchored == "**/"+base { + return true + } + // Relative path from .gitignore dir, e.g. "cmd/wfctl/plan.json". + if rel, err := filepath.Rel(gitignoreDir, planAbs); err == nil { + if anchored == rel || anchored == filepath.ToSlash(rel) { + return true + } + } + } + return false +} diff --git a/cmd/wfctl/infra_plan_gitignore_test.go b/cmd/wfctl/infra_plan_gitignore_test.go new file mode 100644 index 000000000..1e14bcd10 --- /dev/null +++ b/cmd/wfctl/infra_plan_gitignore_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestPlan_WarnsOnMissingGitignoreEntry verifies that runInfraPlan emits a +// stderr warning when the plan output path is not covered by .gitignore. +// plan.json carries semi-sensitive content (env-var fingerprints, resolved +// configs) and must not land in version control by default. +func TestPlan_WarnsOnMissingGitignoreEntry(t *testing.T) { + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("# empty\n"), 0o600); err != nil { + t.Fatalf("write .gitignore: %v", err) + } + cfgPath := filepath.Join(repo, "infra.yaml") + if err := os.WriteFile(cfgPath, []byte(` +modules: + - name: vpc + type: infra.vpc + config: + region: nyc1 +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + planFile := filepath.Join(repo, "plan.json") + + stderr, fnErr := captureStderr(t, func() error { + return runInfraPlan([]string{"--config", cfgPath, "--output", planFile}) + }) + if fnErr != nil { + t.Fatalf("runInfraPlan: %v", fnErr) + } + if !strings.Contains(stderr, "plan.json") || !strings.Contains(stderr, "gitignore") { + t.Errorf("expected gitignore warning mentioning plan.json, got: %q", stderr) + } +} + +// TestPlan_NoWarningWhenGitignored verifies that runInfraPlan stays silent +// (no stderr warning) when the output file is already covered by .gitignore. +func TestPlan_NoWarningWhenGitignored(t *testing.T) { + repo := t.TempDir() + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("plan.json\n"), 0o600); err != nil { + t.Fatalf("write .gitignore: %v", err) + } + cfgPath := filepath.Join(repo, "infra.yaml") + if err := os.WriteFile(cfgPath, []byte(` +modules: + - name: vpc + type: infra.vpc + config: + region: nyc1 +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + planFile := filepath.Join(repo, "plan.json") + + stderr, fnErr := captureStderr(t, func() error { + return runInfraPlan([]string{"--config", cfgPath, "--output", planFile}) + }) + if fnErr != nil { + t.Fatalf("runInfraPlan: %v", fnErr) + } + if strings.Contains(stderr, "gitignore") { + t.Errorf("did not expect gitignore warning when plan.json is gitignored, got: %q", stderr) + } +} + +// TestPlan_NoGitignoreFile_NoWarning verifies the warning is silent when no +// .gitignore exists in the repo (no git context — likely not a tracked repo). +func TestPlan_NoGitignoreFile_NoWarning(t *testing.T) { + repo := t.TempDir() + cfgPath := filepath.Join(repo, "infra.yaml") + if err := os.WriteFile(cfgPath, []byte(` +modules: + - name: vpc + type: infra.vpc + config: + region: nyc1 +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + planFile := filepath.Join(repo, "plan.json") + + stderr, fnErr := captureStderr(t, func() error { + return runInfraPlan([]string{"--config", cfgPath, "--output", planFile}) + }) + if fnErr != nil { + t.Fatalf("runInfraPlan: %v", fnErr) + } + if strings.Contains(stderr, "gitignore") { + t.Errorf("did not expect gitignore warning without .gitignore file, got: %q", stderr) + } +} From 43c8ced040276020fdfafc376bbd2e8d3af4be3c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 19:19:07 -0400 Subject: [PATCH 06/38] feat(iac): typed ErrEnvVarChanged sentinel + plan-stale diagnostic + ComputeDrift sentinel-honoring --- cmd/wfctl/infra.go | 16 +++++ cmd/wfctl/infra_apply_plan_test.go | 95 +++++++++++++++++++++++++ iac/inputsnapshot/compute_drift.go | 46 ++++++++++++ iac/inputsnapshot/compute_drift_test.go | 47 ++++++++++++ iac/inputsnapshot/diagnostic.go | 35 +++++++++ iac/inputsnapshot/errors.go | 11 +++ 6 files changed, 250 insertions(+) create mode 100644 iac/inputsnapshot/compute_drift.go create mode 100644 iac/inputsnapshot/compute_drift_test.go create mode 100644 iac/inputsnapshot/diagnostic.go create mode 100644 iac/inputsnapshot/errors.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index c5e7161de..1ac1d39e7 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -10,6 +10,7 @@ import ( "time" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/inputsnapshot" "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/platform" "github.com/GoCodeAlone/workflow/secrets" @@ -1082,6 +1083,21 @@ func runInfraApply(args []string) error { if plan.DesiredHash == "" { return fmt.Errorf("plan file has no hash — regenerate with: wfctl infra plan -o plan.json") } + // Check the input-fingerprint drift first so the operator gets a + // per-key diagnostic instead of the generic config-hash mismatch. + // (Env-var changes are a strict subset of config-hash differences; + // flagging them here yields the actionable message.) Names list is + // derived from plan.InputSnapshot keys — no separate InputNames field. + if len(plan.InputSnapshot) > 0 { + names := make([]string, 0, len(plan.InputSnapshot)) + for k := range plan.InputSnapshot { + names = append(names, k) + } + applySnap := inputsnapshot.Compute(names, inputsnapshot.OSEnvProvider) + if drift := inputsnapshot.ComputeDrift(plan.InputSnapshot, applySnap); len(drift) > 0 { + return fmt.Errorf("%w\n%s", inputsnapshot.ErrEnvVarChanged, inputsnapshot.FormatStaleError(drift)) + } + } currentHash := desiredStateHash(desired) if plan.DesiredHash != currentHash { return fmt.Errorf("plan stale: config hash mismatch (run wfctl infra plan again)") diff --git a/cmd/wfctl/infra_apply_plan_test.go b/cmd/wfctl/infra_apply_plan_test.go index 829e9cbd8..a68954a87 100644 --- a/cmd/wfctl/infra_apply_plan_test.go +++ b/cmd/wfctl/infra_apply_plan_test.go @@ -2,7 +2,10 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "io" "os" "path/filepath" @@ -10,9 +13,18 @@ import ( "testing" "time" + "github.com/GoCodeAlone/workflow/iac/inputsnapshot" "github.com/GoCodeAlone/workflow/interfaces" ) +// fingerprintForTest matches inputsnapshot.Compute's fingerprint format +// (16-hex-char sha256 prefix) so tests can construct expected plan-time +// snapshots without depending on the concrete env-provider closure. +func fingerprintForTest(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:])[:16] +} + // TestInfraApplyConsumesPlan verifies that wfctl infra apply --plan : // 1. Reads actions from the plan file without calling ComputePlan. // 2. Calls provider.Apply with exactly the plan from the file (identified by plan ID). @@ -399,6 +411,89 @@ modules: } } +// TestApply_PlanStaleDiagnostic_NamesChangedKeys_Persisted verifies that the +// persisted-`--plan` apply path returns the typed inputsnapshot.ErrEnvVarChanged +// sentinel when an env-var fingerprint embedded in the plan differs from the +// env at apply time, and that the error message names the changed key. This +// is the W-1 cross-PR test for the persisted-plan path; the in-process apply +// path is wired in T3.1.5 (W-3a). +func TestApply_PlanStaleDiagnostic_NamesChangedKeys_Persisted(t *testing.T) { + // Plan was generated with old-value; embed its fingerprint in the plan. + t.Setenv("STAGING_PG_PASSWORD", "old-value") + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + if err := os.WriteFile(cfgPath, []byte(` +modules: + - name: test-provider + type: iac.provider + config: + provider: fake-cloud + token: "test-token" + + - name: my-db + type: infra.database + config: + provider: test-provider + engine: postgres + size: s + env_vars: + DATABASE_PASSWORD: "${STAGING_PG_PASSWORD}" +`), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + specs, err := parseInfraResourceSpecs(cfgPath) + if err != nil { + t.Fatalf("parseInfraResourceSpecs: %v", err) + } + plan := interfaces.IaCPlan{ + ID: "stale-input-plan", + DesiredHash: desiredStateHash(specs), + SchemaVersion: 1, + InputSnapshot: map[string]string{ + "STAGING_PG_PASSWORD": fingerprintForTest("old-value"), + }, + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: specs[0]}, + }, + CreatedAt: time.Now().UTC(), + } + planData, err := json.Marshal(plan) + if err != nil { + t.Fatalf("marshal plan: %v", err) + } + planPath := filepath.Join(dir, "plan.json") + if err := os.WriteFile(planPath, planData, 0o600); err != nil { + t.Fatalf("write plan: %v", err) + } + + // Mock provider so apply doesn't try to reach a real cloud. + fake := &applyCapture{} + origResolve := resolveIaCProvider + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return fake, nil, nil + } + defer func() { resolveIaCProvider = origResolve }() + + // Apply with a different value — should trigger the drift diagnostic. + t.Setenv("STAGING_PG_PASSWORD", "new-value") + err = runInfraApply([]string{"--auto-approve", "--config", cfgPath, "--plan", planPath}) + if err == nil { + t.Fatal("expected plan-stale error from changed env-var fingerprint, got nil") + } + if !errors.Is(err, inputsnapshot.ErrEnvVarChanged) { + t.Errorf("expected sentinel inputsnapshot.ErrEnvVarChanged; got %v", err) + } + if !strings.Contains(err.Error(), "STAGING_PG_PASSWORD") { + t.Errorf("error should name the changed key; got: %s", err.Error()) + } + if !strings.Contains(err.Error(), "plan stale") { + t.Errorf("error should preserve the 'plan stale' marker; got: %s", err.Error()) + } + if fake.applyCalled { + t.Error("provider.Apply should not be invoked when plan is stale on input snapshot") + } +} + // TestDesiredStateHash_EmptySpecsProducesStableHash verifies that an empty spec // slice hashes deterministically (not "") so delete-all plans are not blocked. func TestDesiredStateHash_EmptySpecsProducesStableHash(t *testing.T) { diff --git a/iac/inputsnapshot/compute_drift.go b/iac/inputsnapshot/compute_drift.go new file mode 100644 index 000000000..73a106ce5 --- /dev/null +++ b/iac/inputsnapshot/compute_drift.go @@ -0,0 +1,46 @@ +package inputsnapshot + +import "github.com/GoCodeAlone/workflow/interfaces" + +// unsetFingerprintPlaceholder is the in-package constant displayed in the +// ApplyFingerprint field when the var was set at plan time but is missing +// entirely from the applySnap. UNEXPORTED to keep the placeholder a private +// contract between ComputeDrift + FormatStaleError + tests. +const unsetFingerprintPlaceholder = "(unset)" + +// ComputeDrift compares plan-time vs apply-time fingerprint snapshots and +// produces a drift report. Iterates over planSnap keys (no phantom +// InputNames field needed; map keys ARE the names). Honors the in-package +// preservedFingerprint sentinel from snapshot.go — keys whose applySnap +// value equals the sentinel are skipped (sub-action cleanup case). +// +// Cross-function contract: +// - Compute (snapshot.go) passes the sentinel through unhashed. +// - NewTolerantEnvProvider (snapshot.go, sole sanctioned injector) returns +// the sentinel for plan-time-set apply-time-unset vars. +// - ComputeDrift (this function) honors the sentinel by skipping the entry. +func ComputeDrift(planSnap, applySnap map[string]string) []interfaces.DriftEntry { + var drift []interfaces.DriftEntry + for name, planFP := range planSnap { + applyFP, present := applySnap[name] + if !present { + drift = append(drift, interfaces.DriftEntry{ + Name: name, + PlanFingerprint: planFP, + ApplyFingerprint: unsetFingerprintPlaceholder, + }) + continue + } + if applyFP == preservedFingerprint { + continue // Sentinel — sub-action cleanup unset; not real drift. + } + if applyFP != planFP { + drift = append(drift, interfaces.DriftEntry{ + Name: name, + PlanFingerprint: planFP, + ApplyFingerprint: applyFP, + }) + } + } + return drift +} diff --git a/iac/inputsnapshot/compute_drift_test.go b/iac/inputsnapshot/compute_drift_test.go new file mode 100644 index 000000000..7394cea10 --- /dev/null +++ b/iac/inputsnapshot/compute_drift_test.go @@ -0,0 +1,47 @@ +package inputsnapshot + +import "testing" + +func TestComputeDrift_PreservedSentinelSkipsDrift(t *testing.T) { + planSnap := map[string]string{"FOO": "abcdef0000000000"} + applySnap := map[string]string{"FOO": preservedFingerprint} + drift := ComputeDrift(planSnap, applySnap) + if len(drift) != 0 { + t.Errorf("preserved-sentinel should suppress drift; got %+v", drift) + } +} + +func TestComputeDrift_DifferentFingerprint_ReportsDrift(t *testing.T) { + planSnap := map[string]string{"FOO": "abcdef0000000000"} + applySnap := map[string]string{"FOO": "deadbeef00000000"} + drift := ComputeDrift(planSnap, applySnap) + if len(drift) != 1 || drift[0].Name != "FOO" { + t.Errorf("differing fingerprints should produce one drift entry; got %+v", drift) + } +} + +func TestComputeDrift_KeyMissingInApplySnap_ReportsDrift(t *testing.T) { + planSnap := map[string]string{"FOO": "abcdef0000000000"} + applySnap := map[string]string{} // FOO missing entirely + drift := ComputeDrift(planSnap, applySnap) + // Assert behavior, not literal placeholder string. Drift exists, + // ApplyFingerprint differs from PlanFingerprint, and uses the in-package + // unsetFingerprintPlaceholder constant. + if len(drift) != 1 || drift[0].Name != "FOO" { + t.Fatalf("missing key should produce one drift entry for FOO; got %+v", drift) + } + if drift[0].ApplyFingerprint == drift[0].PlanFingerprint { + t.Errorf("ApplyFingerprint should differ from PlanFingerprint; got identical %q", drift[0].ApplyFingerprint) + } + if drift[0].ApplyFingerprint != unsetFingerprintPlaceholder { + t.Errorf("ApplyFingerprint should equal unsetFingerprintPlaceholder; got %q", drift[0].ApplyFingerprint) + } +} + +func TestComputeDrift_MatchingFingerprints_NoDrift(t *testing.T) { + planSnap := map[string]string{"FOO": "abcdef0000000000"} + applySnap := map[string]string{"FOO": "abcdef0000000000"} + if drift := ComputeDrift(planSnap, applySnap); len(drift) != 0 { + t.Errorf("matching fingerprints should produce no drift; got %+v", drift) + } +} diff --git a/iac/inputsnapshot/diagnostic.go b/iac/inputsnapshot/diagnostic.go new file mode 100644 index 000000000..138e18c0c --- /dev/null +++ b/iac/inputsnapshot/diagnostic.go @@ -0,0 +1,35 @@ +package inputsnapshot + +import ( + "fmt" + "sort" + "strings" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// FormatStaleError renders a drift report into the canonical human-readable +// message used at every plan-stale call site (cmd/wfctl/infra.go persisted +// `--plan` path; wfctlhelpers.ApplyPlan in-process path in T3.1.5). Output: +// +// plan stale: %d input(s) changed since plan +// KEY1: fingerprint planFP1 (plan) → applyFP1 (apply) +// KEY2: fingerprint planFP2 (plan) → applyFP2 (apply) +// hint: ensure all env vars referenced by infra.yaml are exported to both Plan and Apply steps +// +// Drift entries are sorted by Name for deterministic output. An empty drift +// report yields the singular line "plan stale: 0 input(s) changed since plan" +// — callers should avoid invoking the formatter when no drift exists. +func FormatStaleError(drift []interfaces.DriftEntry) string { + sorted := make([]interfaces.DriftEntry, len(drift)) + copy(sorted, drift) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + + var b strings.Builder + fmt.Fprintf(&b, "plan stale: %d input(s) changed since plan\n", len(sorted)) + for _, d := range sorted { + fmt.Fprintf(&b, " %s: fingerprint %s (plan) → %s (apply)\n", d.Name, d.PlanFingerprint, d.ApplyFingerprint) + } + b.WriteString(" hint: ensure all env vars referenced by infra.yaml are exported to both Plan and Apply steps") + return b.String() +} diff --git a/iac/inputsnapshot/errors.go b/iac/inputsnapshot/errors.go new file mode 100644 index 000000000..ece08e192 --- /dev/null +++ b/iac/inputsnapshot/errors.go @@ -0,0 +1,11 @@ +package inputsnapshot + +import "errors" + +// ErrEnvVarChanged is the typed sentinel returned by the apply paths +// (cmd/wfctl/infra.go persisted-`--plan` path in W-1; wfctlhelpers.ApplyPlan +// in-process path in W-3a/T3.1.5) when an env var referenced at plan time +// has a different fingerprint at apply time. Callers can match with +// errors.Is(err, ErrEnvVarChanged) to detect the plan-stale case +// independently of the human-readable per-key drift message. +var ErrEnvVarChanged = errors.New("plan stale: env-var changed since plan") From 09bfe8e5594a774e7f8e017040698a5606d095dc Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 19:29:27 -0400 Subject: [PATCH 07/38] =?UTF-8?q?feat(iac):=20add=20refreshoutputs.Refresh?= =?UTF-8?q?=20=E2=80=94=20read-only=20state=20output=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.1 — bounded-concurrency Refresh(ctx, provider, states, opts) that calls ResourceDriver.Read per resource and returns a copy of the state slice with Outputs reconciled to the live values. Default concurrency 8 when Options.Concurrency < 1; otherwise honor the caller's value. On any Read or driver-resolution failure, returns (nil, err) so callers don't half-persist a refresh. Foundation for wfctl infra refresh-outputs (T2.2) and the opt-in apply pre-step (T2.3). Co-Authored-By: Claude Opus 4.7 --- iac/refreshoutputs/refresh.go | 105 ++++++++++++++++++++ iac/refreshoutputs/refresh_test.go | 148 +++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 iac/refreshoutputs/refresh.go create mode 100644 iac/refreshoutputs/refresh_test.go diff --git a/iac/refreshoutputs/refresh.go b/iac/refreshoutputs/refresh.go new file mode 100644 index 000000000..b2087b5e1 --- /dev/null +++ b/iac/refreshoutputs/refresh.go @@ -0,0 +1,105 @@ +// Package refreshoutputs implements read-only state refresh — it reads +// current Outputs from providers and updates the persisted state when fields +// differ. It never invokes Update or Replace at the cloud level; the contract +// is strictly "Read and reconcile in-memory state". +// +// Refresh is the foundation for two consumers in W-2: +// +// - wfctl infra refresh-outputs (T2.2): explicit operator-driven refresh. +// - wfctl infra apply pre-step (T2.3): opt-in via WFCTL_REFRESH_OUTPUTS to +// keep stale outputs from poisoning the planner. +package refreshoutputs + +import ( + "context" + "fmt" + "maps" + "reflect" + "sync" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// defaultConcurrency is the worker count used when Options.Concurrency is +// non-positive. +const defaultConcurrency = 8 + +// Options tunes Refresh behaviour. The zero value is valid and uses +// defaultConcurrency. +type Options struct { + // Concurrency is the maximum number of concurrent Read calls. Values < 1 + // fall back to defaultConcurrency (8). + Concurrency int +} + +// Refresh issues a bounded-concurrency Read against p for each entry in +// states and returns a copy with Outputs reconciled to the live values. +// Resources whose live Outputs are deeply equal to the persisted Outputs are +// returned unchanged (callers can rely on this to skip writes). +// +// Refresh never mutates the input slice. The returned slice is a fresh copy +// of states with possibly-updated Outputs maps; on any Read or +// ResourceDriver failure, Refresh returns (nil, err) and discards partial +// progress so callers don't half-persist a refresh. +// +// Aliasing: for entries whose live Outputs match the persisted Outputs, +// out[i].Outputs is the same map as states[i].Outputs (unchanged maps are +// not cloned). Callers must not mutate Outputs maps in the returned slice +// in place. +func Refresh(ctx context.Context, p interfaces.IaCProvider, states []interfaces.ResourceState, opts Options) ([]interfaces.ResourceState, error) { + if opts.Concurrency < 1 { + opts.Concurrency = defaultConcurrency + } + out := make([]interfaces.ResourceState, len(states)) + copy(out, states) + + sem := make(chan struct{}, opts.Concurrency) + errs := make([]error, len(states)) + var wg sync.WaitGroup + + for i := range states { + sem <- struct{}{} + wg.Go(func() { + defer func() { <-sem }() + errs[i] = refreshOne(ctx, p, &out[i], states[i]) + }) + } + wg.Wait() + + for _, e := range errs { + if e != nil { + return nil, e + } + } + return out, nil +} + +// refreshOne performs a single resource Read and writes the live Outputs +// into dst when they differ from src.Outputs. It returns nil on success or +// the error otherwise. +func refreshOne(ctx context.Context, p interfaces.IaCProvider, dst *interfaces.ResourceState, src interfaces.ResourceState) error { + d, err := p.ResourceDriver(src.Type) + if err != nil { + return err + } + ref := interfaces.ResourceRef{Name: src.Name, Type: src.Type, ProviderID: src.ProviderID} + live, err := d.Read(ctx, ref) + if err != nil { + return fmt.Errorf("could not refresh %q: %w", src.Name, err) + } + if !reflect.DeepEqual(live.Outputs, src.Outputs) { + dst.Outputs = cloneMap(live.Outputs) + } + return nil +} + +// cloneMap returns an independent shallow copy of m. Callers receive a map +// they can mutate without aliasing the live driver output. +func cloneMap(m map[string]any) map[string]any { + if m == nil { + return nil + } + c := make(map[string]any, len(m)) + maps.Copy(c, m) + return c +} diff --git a/iac/refreshoutputs/refresh_test.go b/iac/refreshoutputs/refresh_test.go new file mode 100644 index 000000000..7e5d66e98 --- /dev/null +++ b/iac/refreshoutputs/refresh_test.go @@ -0,0 +1,148 @@ +package refreshoutputs + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// fakeIaCProvider is a minimal IaCProvider stub that returns canned +// ResourceOutput values for Read via fakeResourceDriver. It only implements +// the methods that Refresh exercises (ResourceDriver); the rest panic to +// make accidental use during testing obvious. +type fakeIaCProvider struct { + // readOutputs maps ProviderID → fake driver output Outputs map. nil + // entries produce a ResourceOutput with empty Outputs. + readOutputs map[string]map[string]any + // readErr, when non-nil, causes the driver Read to return the error + // regardless of the resource ref. + readErr error +} + +func (f *fakeIaCProvider) Name() string { panic("not used") } +func (f *fakeIaCProvider) Version() string { panic("not used") } +func (f *fakeIaCProvider) Initialize(context.Context, map[string]any) error { + panic("not used") +} +func (f *fakeIaCProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { + panic("not used") +} +func (f *fakeIaCProvider) Plan(context.Context, []interfaces.ResourceSpec, []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + panic("not used") +} +func (f *fakeIaCProvider) Apply(context.Context, *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + panic("not used") +} +func (f *fakeIaCProvider) Destroy(context.Context, []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + panic("not used") +} +func (f *fakeIaCProvider) Status(context.Context, []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + panic("not used") +} +func (f *fakeIaCProvider) DetectDrift(context.Context, []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + panic("not used") +} +func (f *fakeIaCProvider) Import(context.Context, string, string) (*interfaces.ResourceState, error) { + panic("not used") +} +func (f *fakeIaCProvider) ResolveSizing(string, interfaces.Size, *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + panic("not used") +} +func (f *fakeIaCProvider) SupportedCanonicalKeys() []string { panic("not used") } +func (f *fakeIaCProvider) BootstrapStateBackend(context.Context, map[string]any) (*interfaces.BootstrapResult, error) { + panic("not used") +} +func (f *fakeIaCProvider) Close() error { return nil } + +func (f *fakeIaCProvider) ResourceDriver(string) (interfaces.ResourceDriver, error) { + return &fakeResourceDriver{provider: f}, nil +} + +// fakeResourceDriver answers Read from the parent fakeIaCProvider's +// readOutputs map. All other methods panic to make misuse loud. +type fakeResourceDriver struct { + provider *fakeIaCProvider +} + +func (d *fakeResourceDriver) Create(context.Context, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *fakeResourceDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + if d.provider.readErr != nil { + return nil, d.provider.readErr + } + out := d.provider.readOutputs[ref.ProviderID] + return &interfaces.ResourceOutput{ + Name: ref.Name, + Type: ref.Type, + ProviderID: ref.ProviderID, + Outputs: out, + }, nil +} +func (d *fakeResourceDriver) Update(context.Context, interfaces.ResourceRef, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *fakeResourceDriver) Delete(context.Context, interfaces.ResourceRef) error { + panic("not used") +} +func (d *fakeResourceDriver) Diff(context.Context, interfaces.ResourceSpec, *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + panic("not used") +} +func (d *fakeResourceDriver) HealthCheck(context.Context, interfaces.ResourceRef) (*interfaces.HealthResult, error) { + panic("not used") +} +func (d *fakeResourceDriver) Scale(context.Context, interfaces.ResourceRef, int) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *fakeResourceDriver) SensitiveKeys() []string { return nil } + +func mapsEqual(a, b map[string]any) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + bv, ok := b[k] + if !ok || bv != v { + return false + } + } + return true +} + +func TestRefreshOutputs_ReadsEachResource_PersistsChangedOnly(t *testing.T) { + states := []interfaces.ResourceState{ + {Name: "vpc-1", Type: "infra.vpc", ProviderID: "uuid-1", Outputs: map[string]any{"ip_range": "10.0.0.0/16"}}, + {Name: "vpc-2", Type: "infra.vpc", ProviderID: "uuid-2", Outputs: map[string]any{"ip_range": "10.1.0.0/16"}}, + } + fakeProvider := &fakeIaCProvider{readOutputs: map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, // new "id" field + "uuid-2": {"ip_range": "10.1.0.0/16"}, // unchanged + }} + refreshed, err := Refresh(context.Background(), fakeProvider, states, Options{Concurrency: 2}) + if err != nil { + t.Fatal(err) + } + if got := refreshed[0].Outputs["id"]; got != "uuid-1" { + t.Errorf("vpc-1 should have new 'id' output: %v", refreshed[0].Outputs) + } + if !mapsEqual(refreshed[1].Outputs, states[1].Outputs) { + t.Errorf("vpc-2 should be unchanged: %v vs %v", refreshed[1].Outputs, states[1].Outputs) + } +} + +func TestRefreshOutputs_PartialFailure_ReturnsError(t *testing.T) { + states := []interfaces.ResourceState{ + {Name: "vpc-1", Type: "infra.vpc", ProviderID: "uuid-1"}, + } + fakeProvider := &fakeIaCProvider{readErr: errors.New("network failure")} + _, err := Refresh(context.Background(), fakeProvider, states, Options{Concurrency: 1}) + if err == nil { + t.Fatalf("expected error on Read failure") + } + if !strings.Contains(err.Error(), "could not refresh") { + t.Errorf("error should mention 'could not refresh'; got: %v", err) + } +} From 181e57914d29b8fd35d496e323b07766727befd5 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 20:01:53 -0400 Subject: [PATCH 08/38] feat(iac): add wfctl infra refresh-outputs subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.2 — `wfctl infra refresh-outputs [-c CONFIG] [--env ENV] [--concurrency N]` reads live Outputs for each resource already in state and persists any field-level changes back to the state backend. Read-only at the cloud level — never invokes Update or Replace. Discovers iac.provider modules in the config (with per-env resolution), groups state entries by their owning iac.provider module (ProviderRef-first, falling back to provider type when exactly one module of that type exists), loads each provider once, calls iac/refreshoutputs.Refresh per group, and SaveResource()s any state whose Outputs map changed. When the resolved config has no usable iac.provider module for the requested env, emits the literal error refresh-outputs: provider not configured for env "" verbatim per `fmt.Errorf("refresh-outputs: provider not configured for env %q", env)`. T2.7's runtime-launch-validation asserts against this exact line. Co-Authored-By: Claude Opus 4.7 --- cmd/wfctl/infra.go | 3 + cmd/wfctl/infra_refresh_outputs.go | 244 +++++++++++++++++ cmd/wfctl/infra_refresh_outputs_test.go | 337 ++++++++++++++++++++++++ 3 files changed, 584 insertions(+) create mode 100644 cmd/wfctl/infra_refresh_outputs.go create mode 100644 cmd/wfctl/infra_refresh_outputs_test.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 1ac1d39e7..5f8cc4bbd 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -39,6 +39,8 @@ func runInfra(args []string) error { return runInfraBootstrap(args[1:]) case "outputs": return runInfraOutputs(args[1:]) + case "refresh-outputs": + return runInfraRefreshOutputs(args[1:]) case "align": return runInfraAlign(args[1:]) case "security-check": @@ -62,6 +64,7 @@ Actions: import Import an existing cloud resource into state state Manage IaC state (list, export, import) outputs Print captured resource outputs from state + refresh-outputs Read live outputs and reconcile state (no cloud writes) align Validate IaC config + plan alignment (8 rule families) security-check Scan plan.json for security policy violations diff --git a/cmd/wfctl/infra_refresh_outputs.go b/cmd/wfctl/infra_refresh_outputs.go new file mode 100644 index 000000000..fdd889138 --- /dev/null +++ b/cmd/wfctl/infra_refresh_outputs.go @@ -0,0 +1,244 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "reflect" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/refreshoutputs" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// runInfraRefreshOutputs reads live Outputs from each provider for the +// resources already in state and persists any field-level changes back to +// the state backend. The contract is strictly read-only at the cloud level — +// no Update or Replace is ever invoked. See iac/refreshoutputs/refresh.go +// for the helper this command wraps. +// +// When the resolved config has no usable iac.provider module for the +// requested env, the literal error +// +// refresh-outputs: provider not configured for env "" +// +// is returned so that operators can distinguish a misconfigured workflow +// from a transient cloud-side failure. T2.7 asserts against this exact line. +func runInfraRefreshOutputs(args []string) error { + fs := flag.NewFlagSet("infra refresh-outputs", flag.ContinueOnError) + // Direct help/usage to stdout so `--help` is pipeable and the CI + // runtime-launch validator (T2.7) can capture it via captureStdout. + fs.SetOutput(os.Stdout) + var configFile, envName string + var concurrency int + fs.StringVar(&configFile, "config", "", "Config file") + fs.StringVar(&configFile, "c", "", "Config file (short for --config)") + fs.StringVar(&envName, "env", "", "Environment name (resolves per-module overrides)") + fs.StringVar(&envName, "e", "", "Environment name (short for --env)") + fs.IntVar(&concurrency, "concurrency", 0, "Maximum concurrent Read calls (default 8)") + if err := fs.Parse(args); err != nil { + return err + } + + cfgFile, err := resolveInfraConfig(fs, configFile) + if err != nil { + return err + } + + ctx := context.Background() + + providerDefs, err := discoverIaCProvidersForRefresh(cfgFile, envName) + if err != nil { + return err + } + if len(providerDefs) == 0 { + return fmt.Errorf("refresh-outputs: provider not configured for env %q", envName) + } + + states, err := loadCurrentState(cfgFile, envName) + if err != nil { + return fmt.Errorf("load current state: %w", err) + } + if len(states) == 0 { + fmt.Println("Refresh: no state to refresh.") + return nil + } + + store, err := resolveStateStore(cfgFile, envName) + if err != nil { + return fmt.Errorf("open state store: %w", err) + } + + return refreshOutputsAcrossProviders(ctx, providerDefs, states, store, concurrency, os.Stdout) +} + +// refreshOutputsProviderDef captures everything refresh-outputs needs to +// load and call a single iac.provider module: its module name, the provider +// type, and the env-resolved config. +type refreshOutputsProviderDef struct { + moduleName string + provType string + provCfg map[string]any +} + +// discoverIaCProvidersForRefresh walks cfgFile's modules and returns one +// providerDef per iac.provider module that resolves successfully for envName +// (modules disabled with `environments: { : ~ }` are skipped). When +// envName is "", the top-level module config is used as-is. The returned +// slice preserves declaration order. +func discoverIaCProvidersForRefresh(cfgFile, envName string) ([]refreshOutputsProviderDef, error) { + cfg, err := config.LoadFromFile(cfgFile) + if err != nil { + return nil, fmt.Errorf("load config: %w", err) + } + var defs []refreshOutputsProviderDef + for i := range cfg.Modules { + m := &cfg.Modules[i] + if m.Type != "iac.provider" { + continue + } + var modCfg map[string]any + if envName != "" { + resolved, ok := m.ResolveForEnv(envName) + if !ok { + // Disabled for this env via null override; not "no provider" + // for the env unless every iac.provider is disabled. + continue + } + modCfg = config.ExpandEnvInMap(resolved.Config) + } else { + modCfg = config.ExpandEnvInMap(m.Config) + } + pt, _ := modCfg["provider"].(string) + if pt == "" { + fmt.Fprintf(os.Stderr, "warning: iac.provider %q has no 'provider' field; skipping\n", m.Name) + continue + } + defs = append(defs, refreshOutputsProviderDef{ + moduleName: m.Name, + provType: pt, + provCfg: modCfg, + }) + } + return defs, nil +} + +// refreshOutputsAcrossProviders groups state entries by which iac.provider +// module owns them, calls refreshoutputs.Refresh for each group, and +// persists any state entries whose Outputs changed. It loads each provider +// at most once. +// +// State entries with a non-empty ProviderRef are matched to the iac.provider +// module of the same name. State entries without a ProviderRef fall back to +// the iac.provider module whose provider type matches state.Provider, but +// only when exactly one such module exists; otherwise the fallback is +// ambiguous and the entry is skipped with a warning rather than refreshed +// against the wrong provider. +func refreshOutputsAcrossProviders( + ctx context.Context, + providerDefs []refreshOutputsProviderDef, + states []interfaces.ResourceState, + store infraStateStore, + concurrency int, + stdout io.Writer, +) error { + defByName := make(map[string]refreshOutputsProviderDef, len(providerDefs)) + defsByType := make(map[string][]string) + for _, d := range providerDefs { + defByName[d.moduleName] = d + defsByType[d.provType] = append(defsByType[d.provType], d.moduleName) + } + + groups := make(map[string][]int) // moduleName → indices into states + var groupOrder []string + for i := range states { + s := &states[i] + moduleName := s.ProviderRef + if moduleName == "" && s.Provider != "" { + candidates := defsByType[s.Provider] + if len(candidates) == 1 { + moduleName = candidates[0] + } + } + if moduleName == "" { + fmt.Fprintf(stdout, "Refresh: skipping %q — cannot resolve owning provider (provider_ref=%q, provider=%q)\n", + s.Name, s.ProviderRef, s.Provider) + continue + } + if _, ok := defByName[moduleName]; !ok { + fmt.Fprintf(stdout, "Refresh: skipping %q — provider module %q not declared in config\n", s.Name, moduleName) + continue + } + if _, exists := groups[moduleName]; !exists { + groupOrder = append(groupOrder, moduleName) + } + groups[moduleName] = append(groups[moduleName], i) + } + + updated := 0 + for _, moduleName := range groupOrder { + def := defByName[moduleName] + idxs := groups[moduleName] + groupStates := make([]interfaces.ResourceState, len(idxs)) + for j, idx := range idxs { + groupStates[j] = states[idx] + } + fmt.Fprintf(stdout, "Refresh: reading %d resource(s) via provider %q (%s)...\n", + len(groupStates), moduleName, def.provType) + if err := refreshOneProviderGroup(ctx, def, idxs, groupStates, states, store, concurrency, &updated, stdout); err != nil { + return err + } + } + fmt.Fprintf(stdout, "Refresh: complete — %d resource(s) updated.\n", updated) + return nil +} + +// refreshOneProviderGroup loads a single provider, refreshes its state +// subset, and persists any entries whose Outputs changed. Extracted so the +// closer is `defer`-closed for panic safety and to keep +// refreshOutputsAcrossProviders readable. +func refreshOneProviderGroup( + ctx context.Context, + def refreshOutputsProviderDef, + idxs []int, + groupStates []interfaces.ResourceState, + states []interfaces.ResourceState, + store infraStateStore, + concurrency int, + updated *int, + stdout io.Writer, +) error { + provider, closer, err := resolveIaCProvider(ctx, def.provType, def.provCfg) + if err != nil { + return fmt.Errorf("provider %q (%s): load provider: %w", def.moduleName, def.provType, err) + } + if closer != nil { + defer func() { + if cerr := closer.Close(); cerr != nil { + fmt.Fprintf(os.Stderr, "warning: provider %q shutdown: %v\n", def.provType, cerr) + } + }() + } + refreshed, err := refreshoutputs.Refresh(ctx, provider, groupStates, refreshoutputs.Options{Concurrency: concurrency}) + if err != nil { + return fmt.Errorf("provider %q: %w", def.moduleName, err) + } + for j, idx := range idxs { + fresh := refreshed[j] + // reflect.DeepEqual handles non-comparable nested values + // (slices, maps, structs) that the Outputs maps can carry from + // real cloud APIs. A `==` compare on `any` panics for those. + if reflect.DeepEqual(states[idx].Outputs, fresh.Outputs) { + continue + } + states[idx] = fresh + if err := store.SaveResource(ctx, fresh); err != nil { + return fmt.Errorf("provider %q: persist refreshed %q: %w", def.moduleName, fresh.Name, err) + } + *updated++ + fmt.Fprintf(stdout, "Refresh: updated %s\n", fresh.Name) + } + return nil +} diff --git a/cmd/wfctl/infra_refresh_outputs_test.go b/cmd/wfctl/infra_refresh_outputs_test.go new file mode 100644 index 000000000..65e1acf1a --- /dev/null +++ b/cmd/wfctl/infra_refresh_outputs_test.go @@ -0,0 +1,337 @@ +package main + +import ( + "context" + "errors" + "flag" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// refreshOutputsCmdFakeProvider is the IaCProvider stub used by the +// refresh-outputs subcommand tests. It only needs to answer ResourceDriver → +// fakeResourceDriver.Read with canned outputs; everything else can be a +// safe no-op because the subcommand never calls them on the read-only path. +type refreshOutputsCmdFakeProvider struct { + readOutputs map[string]map[string]any +} + +func (f *refreshOutputsCmdFakeProvider) Name() string { return "fake-refresh-outputs" } +func (f *refreshOutputsCmdFakeProvider) Version() string { return "0.0.0" } +func (f *refreshOutputsCmdFakeProvider) Initialize(_ context.Context, _ map[string]any) error { + return nil +} +func (f *refreshOutputsCmdFakeProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { + return nil +} +func (f *refreshOutputsCmdFakeProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + return &interfaces.IaCPlan{}, nil +} +func (f *refreshOutputsCmdFakeProvider) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + return &interfaces.ApplyResult{}, nil +} +func (f *refreshOutputsCmdFakeProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return &refreshOutputsCmdFakeDriver{outputs: f.readOutputs}, nil +} +func (f *refreshOutputsCmdFakeProvider) SupportedCanonicalKeys() []string { return nil } +func (f *refreshOutputsCmdFakeProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) Close() error { return nil } + +// refreshOutputsCmdFakeDriver answers Read with canned outputs keyed by +// ProviderID. All other ResourceDriver methods panic to make accidental +// non-Read use loud during testing. +type refreshOutputsCmdFakeDriver struct { + outputs map[string]map[string]any +} + +func (d *refreshOutputsCmdFakeDriver) Create(context.Context, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + return &interfaces.ResourceOutput{ + Name: ref.Name, + Type: ref.Type, + ProviderID: ref.ProviderID, + Outputs: d.outputs[ref.ProviderID], + }, nil +} +func (d *refreshOutputsCmdFakeDriver) Update(context.Context, interfaces.ResourceRef, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) Delete(context.Context, interfaces.ResourceRef) error { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) Diff(context.Context, interfaces.ResourceSpec, *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) HealthCheck(context.Context, interfaces.ResourceRef) (*interfaces.HealthResult, error) { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) Scale(context.Context, interfaces.ResourceRef, int) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) SensitiveKeys() []string { return nil } + +// TestRefreshOutputs_CommandHelp verifies that --help prints the standard +// FlagSet usage banner naming the subcommand. The banner is what T2.7's +// runtime-launch validation will exercise via the built binary. +func TestRefreshOutputs_CommandHelp(t *testing.T) { + out, err := captureStdout(t, func() error { + return runInfraRefreshOutputs([]string{"--help"}) + }) + if !errors.Is(err, flag.ErrHelp) { + t.Fatalf("expected flag.ErrHelp, got %v", err) + } + if !strings.Contains(out, "Usage of infra refresh-outputs") { + t.Errorf("help output missing 'Usage of infra refresh-outputs'; got: %s", out) + } +} + +// TestRefreshOutputs_PersistsNewFieldsToState exercises the end-to-end +// persists-new-output path: pre-populate a filesystem state backend with a +// stale VPC entry, swap the IaCProvider loader for a stub that returns an +// extra "id" field on Read, run the subcommand, and verify the field has +// been written back to state. +func TestRefreshOutputs_PersistsNewFieldsToState(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + + cfgPath := filepath.Join(dir, "infra.yaml") + cfg := `modules: + - name: cloud-provider + type: iac.provider + config: + provider: fake-provider + - name: state-store + type: iac.state + config: + backend: filesystem + directory: ` + stateDir + ` + - name: coredump-staging-vpc + type: infra.vpc + config: + provider: cloud-provider +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + + // Pre-populate state with a stale VPC entry (no "id" output yet). + store, err := resolveStateStore(cfgPath, "") + if err != nil { + t.Fatalf("resolveStateStore: %v", err) + } + stale := interfaces.ResourceState{ + ID: "coredump-staging-vpc", + Name: "coredump-staging-vpc", + Type: "infra.vpc", + Provider: "fake-provider", + ProviderRef: "cloud-provider", + ProviderID: "uuid-1", + Outputs: map[string]any{"ip_range": "10.0.0.0/16"}, + } + if err := store.SaveResource(context.Background(), stale); err != nil { + t.Fatalf("seed state: %v", err) + } + + // Swap the provider loader so the test never touches a real cloud. + orig := resolveIaCProvider + defer func() { resolveIaCProvider = orig }() + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return &refreshOutputsCmdFakeProvider{ + readOutputs: map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, + }, + }, nil, nil + } + + if _, err := captureStdout(t, func() error { + return runInfraRefreshOutputs([]string{"-c", cfgPath, "--env", "staging"}) + }); err != nil { + t.Fatalf("runInfraRefreshOutputs: %v", err) + } + + refreshed, err := loadCurrentState(cfgPath, "") + if err != nil { + t.Fatalf("loadCurrentState: %v", err) + } + byName := make(map[string]interfaces.ResourceState, len(refreshed)) + for _, s := range refreshed { + byName[s.Name] = s + } + got := byName["coredump-staging-vpc"] + if id, _ := got.Outputs["id"].(string); id != "uuid-1" { + t.Errorf("expected 'id' field after refresh, got %v", got.Outputs) + } +} + +// TestRefreshOutputs_NonComparableOutputs_DoesNotPanic regression-tests +// the Outputs-changed check against real-world Outputs that contain slices +// and nested maps. A naive `lv != v` on `any` values panics with +// "comparing uncomparable type" the moment a slice or map is involved — +// reflect.DeepEqual is the correct tool. Covers both the unchanged path +// (live == persisted) and the changed path (live grows a new field) so +// neither code direction can regress to the panic. +func TestRefreshOutputs_NonComparableOutputs_DoesNotPanic(t *testing.T) { + for _, tc := range []struct { + name string + liveOutputs map[string]any + expectUpdate bool + }{ + { + name: "unchanged-with-slice-and-nested-map", + liveOutputs: map[string]any{ + "subnet_ids": []string{"a", "b"}, + "tags": map[string]any{"env": "staging"}, + }, + expectUpdate: false, + }, + { + name: "added-field-with-slice-and-nested-map", + liveOutputs: map[string]any{ + "subnet_ids": []string{"a", "b"}, + "tags": map[string]any{"env": "staging"}, + "id": "uuid-1", + }, + expectUpdate: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dir, "infra.yaml") + cfg := `modules: + - name: cloud-provider + type: iac.provider + config: + provider: fake-provider + - name: state-store + type: iac.state + config: + backend: filesystem + directory: ` + stateDir + ` + - name: vpc-1 + type: infra.vpc + config: + provider: cloud-provider +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + + store, err := resolveStateStore(cfgPath, "") + if err != nil { + t.Fatalf("resolveStateStore: %v", err) + } + persisted := interfaces.ResourceState{ + ID: "vpc-1", + Name: "vpc-1", + Type: "infra.vpc", + Provider: "fake-provider", + ProviderRef: "cloud-provider", + ProviderID: "uuid-1", + Outputs: map[string]any{ + "subnet_ids": []string{"a", "b"}, + "tags": map[string]any{"env": "staging"}, + }, + } + if err := store.SaveResource(context.Background(), persisted); err != nil { + t.Fatalf("seed state: %v", err) + } + + orig := resolveIaCProvider + defer func() { resolveIaCProvider = orig }() + liveOutputs := tc.liveOutputs + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return &refreshOutputsCmdFakeProvider{ + readOutputs: map[string]map[string]any{"uuid-1": liveOutputs}, + }, nil, nil + } + + // The critical assertion: this call must NOT panic with + // "comparing uncomparable type". + if _, err := captureStdout(t, func() error { + return runInfraRefreshOutputs([]string{"-c", cfgPath}) + }); err != nil { + t.Fatalf("runInfraRefreshOutputs panicked or errored: %v", err) + } + + refreshed, err := loadCurrentState(cfgPath, "") + if err != nil { + t.Fatalf("loadCurrentState: %v", err) + } + var got interfaces.ResourceState + for _, s := range refreshed { + if s.Name == "vpc-1" { + got = s + break + } + } + if tc.expectUpdate { + if _, ok := got.Outputs["id"]; !ok { + t.Errorf("expected new 'id' field after refresh; got Outputs=%v", got.Outputs) + } + } + // Whether updated or not, post-state must still carry the + // nested values intact. + if _, ok := got.Outputs["subnet_ids"]; !ok { + t.Errorf("expected subnet_ids preserved; got Outputs=%v", got.Outputs) + } + }) + } +} + +// TestRefreshOutputs_NoProviderConfigured_ReturnsLiteralError pins the +// exact stderr line T2.7 asserts against. The wording is load-bearing — +// changing it breaks the runtime-launch-validation gate. +func TestRefreshOutputs_NoProviderConfigured_ReturnsLiteralError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + // Config has no iac.provider modules. + cfg := `modules: + - name: state-store + type: iac.state + config: + backend: filesystem + directory: ` + dir + ` +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + err := runInfraRefreshOutputs([]string{"-c", cfgPath, "--env", "staging"}) + if err == nil { + t.Fatal("expected error when no iac.provider configured") + } + want := `refresh-outputs: provider not configured for env "staging"` + if err.Error() != want { + t.Errorf("error mismatch:\n got: %q\nwant: %q", err.Error(), want) + } +} From bfd1bbe164ff793d615b578e11dcada7fb305526 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 20:19:01 -0400 Subject: [PATCH 09/38] feat(iac): apply-time refresh-outputs pre-step (opt-in via WFCTL_REFRESH_OUTPUTS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.3 — wires iac/refreshoutputs.Refresh into runInfraApply as a pre-plan read-only state reconciliation. Default OFF: operators get pre-W-2 behavior unless they explicitly opt in. Activation rules: - WFCTL_REFRESH_OUTPUTS unset, empty, or unrecognised → no-op (default). - WFCTL_REFRESH_OUTPUTS="1"/"true"/"t" (strconv.ParseBool truthy) → run pre-step. - WFCTL_REFRESH_OUTPUTS="0"/"false"/"f" (strconv.ParseBool falsey) → no-op. Operators who use the "0"/"false" convention to disable a feature get the expected behaviour rather than a presence-only foot-gun. - --skip-refresh → suppress pre-step regardless of env var (for CI environments that force the env var on globally). Behavior: after the existing --refresh drift/prune phase and before the plan/apply dispatch, discovers iac.provider modules with per-env resolution, loads current state, and calls refreshOutputsAcrossProviders to read live Outputs and persist any field-level changes. On any Read or driver-resolution failure, apply aborts with the wrapped error from T2.1's helper (no half-persisted refresh, no plan computed against stale state). Only fires for infra.* configs (legacy platform.* path is silently skipped). Rollback: unset WFCTL_REFRESH_OUTPUTS, pass --skip-refresh, or revert this commit. Reverting removes the pre-step entirely (helper file plus the gated block in infra.go). Co-Authored-By: Claude Opus 4.7 --- cmd/wfctl/infra.go | 14 ++ cmd/wfctl/infra_apply_refresh_pre.go | 99 ++++++++++ cmd/wfctl/infra_apply_refresh_pre_test.go | 210 ++++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 cmd/wfctl/infra_apply_refresh_pre.go create mode 100644 cmd/wfctl/infra_apply_refresh_pre_test.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 5f8cc4bbd..fa0b6e087 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -968,6 +968,8 @@ func runInfraApply(args []string) error { fs.BoolVar(&refreshFlag, "refresh", false, "Detect drift and prune ghost-in-state entries before applying") var allowProtectedPruneFlag bool fs.BoolVar(&allowProtectedPruneFlag, "allow-protected-prune", false, "Allow pruning state entries for resources marked protected: true (requires --refresh)") + var skipRefreshFlag bool + fs.BoolVar(&skipRefreshFlag, "skip-refresh", false, "Skip the WFCTL_REFRESH_OUTPUTS pre-step refresh even if the env var is set") autoApprove := &autoApproveVal showSensitive := showSensitiveVal if err := fs.Parse(args); err != nil { @@ -1070,6 +1072,18 @@ func runInfraApply(args []string) error { } } + // WFCTL_REFRESH_OUTPUTS pre-step (T2.3): when opted in, read live + // Outputs from each provider and persist any field-level changes + // before computing the plan, so apply doesn't make decisions on + // stale state. Default off; --skip-refresh always wins. Only + // applicable for infra.* configs (legacy platform.* path doesn't + // flow through iac/refreshoutputs). + if applyPreStepRefreshEnabled(skipRefreshFlag) && hasInfraModules(cfgFile) { + if err := applyPreStepRefreshOutputs(ctx, cfgFile, envName, os.Stdout); err != nil { + return fmt.Errorf("apply pre-step refresh-outputs: %w", err) + } + } + fmt.Printf("Applying infrastructure from %s...\n", cfgFile) // --plan: dispatch actions from a pre-emitted plan file, skipping ComputePlan. diff --git a/cmd/wfctl/infra_apply_refresh_pre.go b/cmd/wfctl/infra_apply_refresh_pre.go new file mode 100644 index 000000000..b5c63d466 --- /dev/null +++ b/cmd/wfctl/infra_apply_refresh_pre.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "strconv" +) + +// applyPreStepRefreshOutputsEnvVar is the environment variable that opts in +// to running iac/refreshoutputs.Refresh as a pre-step before apply computes +// its plan. The default is OFF so apply behaves identically to pre-W-2 for +// every operator who doesn't set it. +// +// The value is parsed with strconv.ParseBool: "1", "t", "T", "TRUE", +// "true", "True" enable; "0", "f", "F", "FALSE", "false", "False" disable; +// unset, empty, or unrecognised values disable. Operators who use the +// "0" / "false" convention to turn features off therefore get the +// expected behaviour rather than the off-by-one foot-gun a presence-only +// toggle would produce. +const applyPreStepRefreshOutputsEnvVar = "WFCTL_REFRESH_OUTPUTS" + +// applyPreStepRefreshOutputs runs the read-only output refresh against +// every iac.provider declared in cfgFile and persists any state entries +// whose Outputs changed. It is the apply-time counterpart to wfctl infra +// refresh-outputs (T2.2): same helpers, same error semantics. On any Read +// or driver-resolution failure, it returns the wrapped error so apply +// aborts before computing a plan against stale outputs. +// +// This function exists as its own file so reverting commit +// "feat(iac): apply-time refresh-outputs pre-step ..." removes the entire +// code path in one operation. The caller in runInfraApply gates this on +// three conditions, all of which must be true for the pre-step to fire: +// +// - applyPreStepRefreshEnabled returns true (WFCTL_REFRESH_OUTPUTS +// parses to true and --skip-refresh was not passed). +// - hasInfraModules(cfgFile) is true (legacy platform.* configs are +// skipped — they don't flow through iac/refreshoutputs). +// +// The helper itself is purely additive on top of those gates: it never +// short-circuits on values it would otherwise accept, so a caller that +// has already decided to refresh can rely on the helper to do exactly +// that. +func applyPreStepRefreshOutputs(ctx context.Context, cfgFile, envName string, stdout io.Writer) error { + providerDefs, err := discoverIaCProvidersForRefresh(cfgFile, envName) + if err != nil { + return err + } + if len(providerDefs) == 0 { + // No provider for this env — log and return nil rather than + // aborting apply: the downstream applyInfraModules call will + // produce a more actionable error if a provider was actually + // expected. The log line makes the no-op visible to operators + // who explicitly opted in. + fmt.Fprintln(stdout, "Refresh pre-step: no iac.provider modules in config; skipping.") + return nil + } + + states, err := loadCurrentState(cfgFile, envName) + if err != nil { + return fmt.Errorf("load current state: %w", err) + } + if len(states) == 0 { + return nil + } + + store, err := resolveStateStore(cfgFile, envName) + if err != nil { + return fmt.Errorf("open state store: %w", err) + } + + fmt.Fprintln(stdout, "Refreshing outputs from cloud (read-only)...") + return refreshOutputsAcrossProviders(ctx, providerDefs, states, store, 0 /* default concurrency */, stdout) +} + +// applyPreStepRefreshEnabled reports whether the opt-in env var parses +// to true AND --skip-refresh was not passed. The flag always wins so +// operators can disable the pre-step in environments where the env var +// is forced on globally (e.g. CI that exports it for every job). +// +// Empty/unset and unrecognised values both disable: the env var is +// strictly opt-in, never opt-out. "0" / "false" therefore disable +// rather than (mis-)enabling, which is the convention every operator +// expects. +func applyPreStepRefreshEnabled(skipRefreshFlag bool) bool { + if skipRefreshFlag { + return false + } + v := os.Getenv(applyPreStepRefreshOutputsEnvVar) + if v == "" { + return false + } + enabled, err := strconv.ParseBool(v) + if err != nil { + return false + } + return enabled +} diff --git a/cmd/wfctl/infra_apply_refresh_pre_test.go b/cmd/wfctl/infra_apply_refresh_pre_test.go new file mode 100644 index 000000000..4a2406d49 --- /dev/null +++ b/cmd/wfctl/infra_apply_refresh_pre_test.go @@ -0,0 +1,210 @@ +package main + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// writeApplyPreRefreshConfig writes an infra YAML at /infra.yaml with +// auto_bootstrap disabled (so the apply path doesn't try to run bootstrap), +// a filesystem state backend pointed at /state, a single iac.provider +// module, and one infra.vpc resource keyed to it. Returned path is the +// absolute config path. +func writeApplyPreRefreshConfig(t *testing.T, dir string) string { + t.Helper() + stateDir := filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dir, "infra.yaml") + cfg := `infra: + auto_bootstrap: false +modules: + - name: cloud-provider + type: iac.provider + config: + provider: fake-provider + - name: state-store + type: iac.state + config: + backend: filesystem + directory: ` + stateDir + ` + - name: coredump-staging-vpc + type: infra.vpc + config: + provider: cloud-provider +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + return cfgPath +} + +// seedStaleVPCState writes a single VPC ResourceState to the configured +// filesystem state backend with no "id" output, ready for refresh to add it. +func seedStaleVPCState(t *testing.T, cfgPath string) { + t.Helper() + store, err := resolveStateStore(cfgPath, "") + if err != nil { + t.Fatalf("resolveStateStore: %v", err) + } + stale := interfaces.ResourceState{ + ID: "coredump-staging-vpc", + Name: "coredump-staging-vpc", + Type: "infra.vpc", + Provider: "fake-provider", + ProviderRef: "cloud-provider", + ProviderID: "uuid-1", + Outputs: map[string]any{"ip_range": "10.0.0.0/16"}, + } + if err := store.SaveResource(context.Background(), stale); err != nil { + t.Fatalf("seed state: %v", err) + } +} + +// loadVPCStateOutputs returns the Outputs map for coredump-staging-vpc as +// persisted on disk after the apply call returns. +func loadVPCStateOutputs(t *testing.T, cfgPath string) map[string]any { + t.Helper() + states, err := loadCurrentState(cfgPath, "") + if err != nil { + t.Fatalf("loadCurrentState: %v", err) + } + for _, s := range states { + if s.Name == "coredump-staging-vpc" { + return s.Outputs + } + } + t.Fatalf("coredump-staging-vpc not in state after apply; got %d entries", len(states)) + return nil +} + +// installFakeRefreshProvider swaps the global resolveIaCProvider for the +// duration of the test with a stub that handles both Read (via the embedded +// driver) and Apply (returning an empty ApplyResult so the apply path is a +// no-op). Returns the cleanup function. +func installFakeRefreshProvider(t *testing.T, liveOutputs map[string]map[string]any) func() { + t.Helper() + orig := resolveIaCProvider + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return &refreshOutputsCmdFakeProvider{readOutputs: liveOutputs}, nil, nil + } + return func() { resolveIaCProvider = orig } +} + +// TestApply_PreStepRefresh_OptInViaEnvVar verifies that setting +// WFCTL_REFRESH_OUTPUTS=1 causes runInfraApply to read live Outputs for +// every state entry and persist new fields BEFORE the plan+apply phase. +// The end-to-end signal is the "id" field present in state on disk after +// apply returns. +func TestApply_PreStepRefresh_OptInViaEnvVar(t *testing.T) { + t.Setenv("WFCTL_REFRESH_OUTPUTS", "1") + dir := t.TempDir() + cfgPath := writeApplyPreRefreshConfig(t, dir) + seedStaleVPCState(t, cfgPath) + + cleanup := installFakeRefreshProvider(t, map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, + }) + defer cleanup() + + if _, err := captureStdout(t, func() error { + return runInfraApply([]string{"--auto-approve", "-c", cfgPath}) + }); err != nil { + t.Fatalf("runInfraApply: %v", err) + } + + got := loadVPCStateOutputs(t, cfgPath) + if id, _ := got["id"].(string); id != "uuid-1" { + t.Errorf("apply pre-refresh should populate 'id'; got %v", got) + } +} + +// TestApply_PreStepRefresh_DisabledByDefault verifies the opt-in semantics: +// without WFCTL_REFRESH_OUTPUTS set, the pre-step is skipped and stale +// state remains stale. +func TestApply_PreStepRefresh_DisabledByDefault(t *testing.T) { + // Clear any inherited value to make the test resilient under -count=N. + t.Setenv("WFCTL_REFRESH_OUTPUTS", "") + os.Unsetenv("WFCTL_REFRESH_OUTPUTS") + dir := t.TempDir() + cfgPath := writeApplyPreRefreshConfig(t, dir) + seedStaleVPCState(t, cfgPath) + + cleanup := installFakeRefreshProvider(t, map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, + }) + defer cleanup() + + if _, err := captureStdout(t, func() error { + return runInfraApply([]string{"--auto-approve", "-c", cfgPath}) + }); err != nil { + t.Fatalf("runInfraApply: %v", err) + } + + got := loadVPCStateOutputs(t, cfgPath) + if _, ok := got["id"]; ok { + t.Errorf("default-off pre-refresh should not populate 'id'; got %v", got) + } +} + +// TestApply_PreStepRefresh_EnvVarFalseyValueDisables pins the +// strconv.ParseBool semantic: "0" / "false" / "off" don't enable the +// pre-step. Operators routinely set env vars to "0" to disable a +// feature; without ParseBool that would be a foot-gun (any non-empty +// value enables, including "0"). One representative falsey value is +// enough — strconv.ParseBool's accept set is well-tested upstream. +func TestApply_PreStepRefresh_EnvVarFalseyValueDisables(t *testing.T) { + t.Setenv("WFCTL_REFRESH_OUTPUTS", "0") + dir := t.TempDir() + cfgPath := writeApplyPreRefreshConfig(t, dir) + seedStaleVPCState(t, cfgPath) + + cleanup := installFakeRefreshProvider(t, map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, + }) + defer cleanup() + + if _, err := captureStdout(t, func() error { + return runInfraApply([]string{"--auto-approve", "-c", cfgPath}) + }); err != nil { + t.Fatalf("runInfraApply: %v", err) + } + + got := loadVPCStateOutputs(t, cfgPath) + if _, ok := got["id"]; ok { + t.Errorf("WFCTL_REFRESH_OUTPUTS=0 must disable pre-refresh; got %v", got) + } +} + +// TestApply_PreStepRefresh_SkipFlagOverridesEnvVar verifies that +// --skip-refresh trumps WFCTL_REFRESH_OUTPUTS. Operators need a way to +// disable the pre-step in the same invocation that has the env var set +// (for example, when a CI environment forces it on globally). +func TestApply_PreStepRefresh_SkipFlagOverridesEnvVar(t *testing.T) { + t.Setenv("WFCTL_REFRESH_OUTPUTS", "1") + dir := t.TempDir() + cfgPath := writeApplyPreRefreshConfig(t, dir) + seedStaleVPCState(t, cfgPath) + + cleanup := installFakeRefreshProvider(t, map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, + }) + defer cleanup() + + if _, err := captureStdout(t, func() error { + return runInfraApply([]string{"--auto-approve", "--skip-refresh", "-c", cfgPath}) + }); err != nil { + t.Fatalf("runInfraApply: %v", err) + } + + got := loadVPCStateOutputs(t, cfgPath) + if _, ok := got["id"]; ok { + t.Errorf("--skip-refresh should suppress refresh even with env var set; got %v", got) + } +} From 2d77af902ece9b6a16268af5a53b98b5a16dfb27 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 20:31:15 -0400 Subject: [PATCH 10/38] test(iac): concurrency stress test for refreshoutputs.Refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.5 — pure-package stress test in iac/refreshoutputs/. Drives Refresh with 100 fake resources at Concurrency=8 and asserts: 1. No deadlock (10s watchdog around the call). 2. Read called exactly once per ProviderID (atomic per-ID counter). 3. Every refreshed state carries the live Outputs map — no write-into-wrong-slot bug under concurrency. 4. Concurrent in-flight peak between 2 and the requested cap, proving both that parallelism happened AND that the semaphore enforced its limit. The countingDriver introduces a 5ms sleep per Read so the bounded pool actually queues at the cap (5ms × 100 / 8 ≈ 63ms total at peak; well under the 10s watchdog). Test runs ~1.5s wall. Co-Authored-By: Claude Opus 4.7 --- .../refresh_concurrency_test.go | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 iac/refreshoutputs/refresh_concurrency_test.go diff --git a/iac/refreshoutputs/refresh_concurrency_test.go b/iac/refreshoutputs/refresh_concurrency_test.go new file mode 100644 index 000000000..da747cb65 --- /dev/null +++ b/iac/refreshoutputs/refresh_concurrency_test.go @@ -0,0 +1,209 @@ +package refreshoutputs + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// countingProvider is a stress-test IaCProvider that hands out a single +// shared countingDriver. Every Read call increments the per-ProviderID +// counter so the test can assert "exactly once per resource" after +// Refresh returns. +type countingProvider struct { + driver *countingDriver +} + +func (p *countingProvider) Name() string { panic("not used") } +func (p *countingProvider) Version() string { panic("not used") } +func (p *countingProvider) Initialize(context.Context, map[string]any) error { + panic("not used") +} +func (p *countingProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { + panic("not used") +} +func (p *countingProvider) Plan(context.Context, []interfaces.ResourceSpec, []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + panic("not used") +} +func (p *countingProvider) Apply(context.Context, *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + panic("not used") +} +func (p *countingProvider) Destroy(context.Context, []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + panic("not used") +} +func (p *countingProvider) Status(context.Context, []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + panic("not used") +} +func (p *countingProvider) DetectDrift(context.Context, []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + panic("not used") +} +func (p *countingProvider) Import(context.Context, string, string) (*interfaces.ResourceState, error) { + panic("not used") +} +func (p *countingProvider) ResolveSizing(string, interfaces.Size, *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + panic("not used") +} +func (p *countingProvider) SupportedCanonicalKeys() []string { panic("not used") } +func (p *countingProvider) BootstrapStateBackend(context.Context, map[string]any) (*interfaces.BootstrapResult, error) { + panic("not used") +} +func (p *countingProvider) Close() error { return nil } + +func (p *countingProvider) ResourceDriver(string) (interfaces.ResourceDriver, error) { + return p.driver, nil +} + +// countingDriver atomically tracks how many times Read was called for +// each ProviderID and how many goroutines are inside Read at once. +// concurrentPeak gives the test a way to assert that the bounded +// semaphore actually enforced its cap. +type countingDriver struct { + mu sync.Mutex + callsByID map[string]int + inFlight atomic.Int32 + concurrentPeak atomic.Int32 +} + +func (d *countingDriver) Create(context.Context, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *countingDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + now := d.inFlight.Add(1) + defer d.inFlight.Add(-1) + for { + peak := d.concurrentPeak.Load() + if now <= peak || d.concurrentPeak.CompareAndSwap(peak, now) { + break + } + } + + // Hold each call long enough that, with N=100 and Concurrency=8, the + // bounded pool will queue up: every dispatched call MUST overlap with + // at least one other or the test's concurrency-cap check is vacuous. + // 5ms × 100 / 8 ≈ 63ms total wall time at the cap, well under the + // watchdog's 10s budget. + time.Sleep(5 * time.Millisecond) + + d.mu.Lock() + d.callsByID[ref.ProviderID]++ + d.mu.Unlock() + return &interfaces.ResourceOutput{ + Name: ref.Name, + Type: ref.Type, + ProviderID: ref.ProviderID, + // Add a "id" field so Refresh sees a diff and copies the new + // Outputs into out[i].Outputs — gives the test a positive + // per-resource signal that every refresh propagated. + Outputs: map[string]any{"id": ref.ProviderID, "fresh": true}, + }, nil +} +func (d *countingDriver) Update(context.Context, interfaces.ResourceRef, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *countingDriver) Delete(context.Context, interfaces.ResourceRef) error { + panic("not used") +} +func (d *countingDriver) Diff(context.Context, interfaces.ResourceSpec, *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + panic("not used") +} +func (d *countingDriver) HealthCheck(context.Context, interfaces.ResourceRef) (*interfaces.HealthResult, error) { + panic("not used") +} +func (d *countingDriver) Scale(context.Context, interfaces.ResourceRef, int) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *countingDriver) SensitiveKeys() []string { return nil } + +// TestRefresh_ConcurrencyStress_NoDeadlock_AllRefreshed_OnceEach exercises +// Refresh against 100 resources with Concurrency=8 and asserts: +// +// 1. No deadlock — Refresh returns within a generous watchdog budget. +// Caught by `done` channel + 10s timeout `select`. +// 2. Read was called exactly once per resource — no double-dispatch +// hidden bugs in the semaphore acquire/release pairing. +// 3. The resulting state slice carries the refreshed Outputs for every +// entry — no goroutine wrote into the wrong out[i] under concurrency. +// 4. Concurrent peak in flight is between 2 and the requested +// concurrency cap. ≥2 confirms the bounded pool actually parallelised +// work; ≤cap confirms the semaphore enforced its limit. +func TestRefresh_ConcurrencyStress_NoDeadlock_AllRefreshed_OnceEach(t *testing.T) { + const ( + nResources = 100 + concurrency = 8 + watchdog = 10 * time.Second + ) + + states := make([]interfaces.ResourceState, nResources) + for i := range states { + id := fmt.Sprintf("uuid-%03d", i) + states[i] = interfaces.ResourceState{ + Name: fmt.Sprintf("vpc-%03d", i), + Type: "infra.vpc", + ProviderID: id, + Outputs: map[string]any{"ip_range": "10.0.0.0/16"}, + } + } + + driver := &countingDriver{callsByID: make(map[string]int, nResources)} + provider := &countingProvider{driver: driver} + + done := make(chan struct{}) + var refreshed []interfaces.ResourceState + var refreshErr error + go func() { + defer close(done) + refreshed, refreshErr = Refresh(context.Background(), provider, states, Options{Concurrency: concurrency}) + }() + select { + case <-done: + // Refresh returned — proceed with assertions. + case <-time.After(watchdog): + t.Fatalf("Refresh did not return within %s — possible deadlock", watchdog) + } + + if refreshErr != nil { + t.Fatalf("Refresh: %v", refreshErr) + } + if len(refreshed) != nResources { + t.Fatalf("expected %d refreshed states, got %d", nResources, len(refreshed)) + } + + // Each ProviderID should have been Read exactly once. + driver.mu.Lock() + defer driver.mu.Unlock() + if len(driver.callsByID) != nResources { + t.Errorf("expected reads for %d distinct ProviderIDs, got %d", nResources, len(driver.callsByID)) + } + for id, n := range driver.callsByID { + if n != 1 { + t.Errorf("ProviderID %q: Read called %d times, want exactly 1", id, n) + } + } + + // Every state in the result must carry the refreshed Outputs map + // (driver returns "id" + "fresh": true on every Read). + for i, s := range refreshed { + if got, _ := s.Outputs["id"].(string); got != fmt.Sprintf("uuid-%03d", i) { + t.Errorf("refreshed[%d]: Outputs[\"id\"]=%q, want uuid-%03d", i, got, i) + } + if got, _ := s.Outputs["fresh"].(bool); !got { + t.Errorf("refreshed[%d]: Outputs[\"fresh\"]=%v, want true", i, got) + } + } + + // Concurrency-cap invariants: peak inflight must have been >1 to + // prove parallelism happened, and ≤concurrency to prove the + // semaphore enforced its limit. + peak := int(driver.concurrentPeak.Load()) + if peak < 2 { + t.Errorf("concurrent peak in flight = %d; expected >=2 (parallelism not exercised)", peak) + } + if peak > concurrency { + t.Errorf("concurrent peak in flight = %d; expected <=%d (semaphore cap exceeded)", peak, concurrency) + } +} From a892c1d99eb04208524fbb50d0906bf37111e00f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 20:33:36 -0400 Subject: [PATCH 11/38] docs(wfctl): document infra refresh-outputs subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.6 — adds the infra refresh-outputs section to docs/WFCTL.md: - New row in the Command Tree mermaid graph. - New row in the infra Action table. - Dedicated #### subsection with usage, flag table, behavior summary, literal-error contract (load-bearing per T2.7), apply-time pre-step semantics (WFCTL_REFRESH_OUTPUTS, --skip-refresh), and three representative examples. See also: docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md records the T2.3 plan-deviation (ParseBool vs plan-literal presence check) that the docs in this commit accurately reflect. Verification — plan §T2.6 line 1090 invocation `mdformat --check docs/WFCTL.md && find docs -name "*.md" -exec markdown-link-check {} +` ran with locally-installed mdformat 1.0.0 (pip) and markdown-link-check 3.14.2 (npm): $ mdformat --check docs/WFCTL.md Error: File "docs/WFCTL.md" is not formatted. exit=1 This failure is PRE-EXISTING. Verified by checking out the file at the W-2 T2.2 tip (181e579) before any T2.6 edits and rerunning mdformat against it: identical error. docs/WFCTL.md has never been mdformat-formatted in this repo. Reformatting the entire file is out of scope for T2.6 (would introduce a multi-thousand-line unrelated diff). T2.6's own additions follow the existing in-file conventions exactly. $ markdown-link-check docs/WFCTL.md FILE: docs/WFCTL.md [✓] https://github.com/GoCodeAlone/workflow [✓] #build-ui [✓] mcp.md 3 links checked. exit=0 docs/WFCTL.md has zero broken links — including the new refresh-outputs section. The directory-wide scan reports 7 broken links in unrelated files (self-improvement-tutorial.md, getting-started.md, etc.); all are pre-existing and out of scope. T2.7 runtime-launch-validation transcript (folded into this commit body per the "Files: none new" plan note for T2.7): $ GOWORK=off go build -o /tmp/wfctl ./cmd/wfctl exit=0 $ /tmp/wfctl infra refresh-outputs --help Usage of infra refresh-outputs: -c string Config file (short for --config) -concurrency int Maximum concurrent Read calls (default 8) -config string Config file -e string Environment name (short for --env) -env string Environment name (resolves per-module overrides) exit=0 $ cat /tmp/t27-fake.yaml modules: - name: state-store type: iac.state config: backend: filesystem directory: /tmp/t27-fake-state $ /tmp/wfctl infra refresh-outputs -c /tmp/t27-fake.yaml --env staging error: refresh-outputs: provider not configured for env "staging" exit=1 No panic, no stack trace. Stderr line is the verbatim literal pinned by T2.7 (plan line 1098), produced by T2.2's fmt.Errorf("refresh-outputs: provider not configured for env %q", env) at cmd/wfctl/infra_refresh_outputs.go:49. PR W-2 mandate (plan line 1101): $ GOWORK=off go test ./iac/refreshoutputs/... ./cmd/wfctl/... -count=1 -race ok github.com/GoCodeAlone/workflow/iac/refreshoutputs 1.405s ok github.com/GoCodeAlone/workflow/cmd/wfctl 10.485s Manual smoke against staging-PG: not run — no staging-PG available in this worktree environment. Plan line 1102 marks this "if available", so deferring to the operator landing the PR. Co-Authored-By: Claude Opus 4.7 --- docs/WFCTL.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/WFCTL.md b/docs/WFCTL.md index bc5f908d9..93063e910 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -96,6 +96,7 @@ graph TD infra --> infra-bootstrap["bootstrap"] infra --> infra-state["state"] infra --> infra-outputs["outputs"] + infra --> infra-refresh-outputs["refresh-outputs"] infra-state --> infra-state-list["list"] infra-state --> infra-state-export["export"] @@ -1171,6 +1172,7 @@ wfctl infra [options] [config.yaml] | `bootstrap` | Generate secrets and initialise state backend before first apply | | `state` | Manage state storage (list/export/import) | | `outputs` | Print resource outputs from state (yaml/json/env formats) | +| `refresh-outputs` | Read live outputs from each provider and reconcile state (no cloud writes) | | Flag | Default | Description | |------|---------|-------------| @@ -1216,6 +1218,63 @@ wfctl infra bootstrap -c infra.yaml --env staging --force-rotate NATS_AUTH_TOKEN wfctl infra bootstrap -c infra.yaml --force-rotate FOO --force-rotate BAR ``` +#### `infra refresh-outputs` + +Read live outputs from each `iac.provider` for resources already in state and persist any field-level changes back to the state backend. The contract is strictly read-only at the cloud level — `refresh-outputs` never invokes Update or Replace. + +``` +wfctl infra refresh-outputs [-c CONFIG] [--env ENV] [--concurrency N] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `-c`, `--config` | _(auto-detected)_ | Config file (searches `infra.yaml`, `config/infra.yaml`) | +| `-e`, `--env` | `` | Environment name (resolves per-module overrides; iac.provider modules disabled for the env are skipped) | +| `--concurrency` | `8` | Maximum concurrent Read calls. Values < 1 fall back to the default. | + +**Behavior:** + +- Discovers `iac.provider` modules with per-env resolution. +- Loads current state from the configured `iac.state` backend. +- Groups state entries by their owning provider module (`provider_ref` first, falling back to provider type when exactly one module of that type is declared). +- Calls each provider's `ResourceDriver.Read` once per resource via the bounded-concurrency `iac/refreshoutputs.Refresh` helper. +- Persists any state entry whose `outputs` map changed — entries whose live outputs equal the persisted outputs are left alone. + +**Errors:** + +When the resolved config has no usable `iac.provider` module for the requested env, `wfctl` exits 1 with the literal stderr line: + +``` +error: refresh-outputs: provider not configured for env "" +``` + +This wording is load-bearing — CI gates and runtime-launch validation pin the exact form. On any provider Read or driver-resolution failure, the command returns the wrapped error from the `iac/refreshoutputs` helper without persisting partial progress. + +**Apply-time pre-step (opt-in):** + +`wfctl infra apply` can run the same refresh as a pre-plan step, ensuring the planner doesn't make decisions against stale outputs. + +| Variable / Flag | Effect | +|------|---------| +| `WFCTL_REFRESH_OUTPUTS=1` (or any `strconv.ParseBool` truthy value) | Enable the apply pre-step. | +| `WFCTL_REFRESH_OUTPUTS=0` (or any falsey value, empty, or unrecognised) | Disable the apply pre-step (default). | +| `wfctl infra apply --skip-refresh` | Suppress the apply pre-step regardless of the env var (CI escape hatch). | + +The pre-step only fires for `infra.*` configs; legacy `platform.*` configs are silently skipped. + +**Examples:** + +```bash +# One-off explicit refresh against the staging env. +wfctl infra refresh-outputs -c infra.yaml --env staging + +# Apply with pre-plan refresh enabled. +WFCTL_REFRESH_OUTPUTS=1 wfctl infra apply --auto-approve -c infra.yaml --env staging + +# Apply with pre-step suppressed even though CI exports the env var. +WFCTL_REFRESH_OUTPUTS=1 wfctl infra apply --auto-approve --skip-refresh -c infra.yaml +``` + --- ### `docs generate` From 8dafaa5f87fa507b0ba7ebf12902ac5bdeb57a83 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 20:42:39 -0400 Subject: [PATCH 12/38] =?UTF-8?q?docs(adr):=20record=20WFCTL=5FREFRESH=5FO?= =?UTF-8?q?UTPUTS=20ParseBool=20semantics=20deviation=20from=20plan=20?= =?UTF-8?q?=C2=A7T2.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 006 — formalises the spec-vs-quality-review trade-off recorded during W-2 T2.3 review: - Plan §T2.3 line 1061 specified `os.Getenv("WFCTL_REFRESH_OUTPUTS") != ""`. - Code-reviewer flagged this as a foot-gun (=0 mis-enables). - Implementation at cmd/wfctl/infra_apply_refresh_pre.go (bfd1bbe) uses strconv.ParseBool so falsey values explicitly disable. - Spec-reviewer accepted post-hoc and requested this ADR per superpowers:recording-decisions. - Team-lead approved option-1 (approve-as-is + follow-up ADR) over a plan revert; provenance recorded in the ADR itself. Captures the rejected alternative, the rationale, references back to the plan spec, the implementation site, the pinning test, and the operator-facing docs. Co-Authored-By: Claude Opus 4.7 --- ...wfctl-refresh-outputs-env-var-parsebool.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md diff --git a/docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md b/docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md new file mode 100644 index 000000000..09e8859eb --- /dev/null +++ b/docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md @@ -0,0 +1,51 @@ +# ADR 006: WFCTL_REFRESH_OUTPUTS uses strconv.ParseBool, not bare presence + +## Status + +Accepted + +## Context + +W-2 Task T2.3 (`docs/plans/2026-05-03-iac-conformance-and-replace.md`, line 1061) specified the apply-time pre-step opt-in gate as a bare presence check: + +```go +if os.Getenv("WFCTL_REFRESH_OUTPUTS") != "" { + // run pre-step +} +``` + +Under that contract, any non-empty value enables the pre-step — including values an operator would universally read as disabling: `0`, `false`, `no`, `off`. T2.3's first quality review (code-reviewer) flagged this as a foot-gun: operators reach for the `=0` / `=false` convention to disable a feature, and the bare-presence check turns that convention into the opposite of its intent. The original commit body also leaned into the wrong mental model by listing `"yes"` as an enabling example, reinforcing the misread that the value was being parsed. + +`--skip-refresh` exists as a robust off-switch override, so an operator who falls into the foot-gun has an escape hatch — but discovering it requires reading the docs after the surprise hit, by which point a planner has already burned a `Read` round-trip on every state entry. + +## Decision + +Implement the gate using `strconv.ParseBool` instead of bare presence. The accept set is well-known to Go developers and matches every common operator convention: + +- Truthy enables: `1`, `t`, `T`, `TRUE`, `true`, `True` +- Falsey explicitly disables: `0`, `f`, `F`, `FALSE`, `false`, `False` +- Empty / unset / unrecognised: disabled (default; never an exception) + +`--skip-refresh` continues to override the env var unconditionally, preserving the CI escape-hatch contract for environments where the env var is forced on globally. + +## Consequences + +**Positive:** + +- Operator surprise eliminated: `WFCTL_REFRESH_OUTPUTS=0` disables, as expected. +- Self-documenting contract: `ParseBool` is a familiar Go idiom; the godoc on `applyPreStepRefreshEnabled` enumerates the accept set. +- Future-proofed against the same bug class for other refresh-outputs related env vars added in W-3+. + +**Negative:** + +- Plan-deviation: the implementation diverges from `docs/plans/2026-05-03-iac-conformance-and-replace.md` line 1061's literal contract. Recorded in this ADR so future contributors see the rejected alternative and the reasoning, rather than discovering it via `git blame` archaeology. A future plan revision should reflect the actual implementation; out of scope for this ADR. +- Operators who set the env var to an unrecognised string (e.g. `WFCTL_REFRESH_OUTPUTS=enabled`) silently get the disabled default. The accept set is intentionally narrow; the alternative — accepting any non-empty as truthy — is exactly the foot-gun this ADR rejects. Mitigated by the doc table in `docs/WFCTL.md` enumerating the truthy/falsey values explicitly. + +**Provenance:** decided by Claude (autonomous-pipeline team-lead) after code-review/spec-review consensus on 2026-05-04. code-reviewer surfaced the foot-gun during T2.3 quality review; implementer prepared the ParseBool change; spec-reviewer requested this ADR post-hoc; team-lead approved option-1 (approve-as-is + follow-up ADR) over plan revert. + +## References + +- Plan task spec: `docs/plans/2026-05-03-iac-conformance-and-replace.md` §T2.3 (line 1061) +- Implementation: `cmd/wfctl/infra_apply_refresh_pre.go::applyPreStepRefreshEnabled` (commit `bfd1bbe`) +- Test pinning the falsey-disables semantic: `cmd/wfctl/infra_apply_refresh_pre_test.go::TestApply_PreStepRefresh_EnvVarFalseyValueDisables` +- Operator-facing docs: `docs/WFCTL.md` → `infra refresh-outputs` → "Apply-time pre-step (opt-in)" From 695a070b5ff61faae1935760231e479fe21a55d3 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 20:56:56 -0400 Subject: [PATCH 13/38] feat(iac): plugin manifest gains iacProvider.computePlanVersion (default v1) --- plugin/sdk/manifest.go | 121 ++++++++++++++++++++++++++++++++ plugin/sdk/manifest_schema.json | 23 ++++++ plugin/sdk/manifest_test.go | 44 ++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 plugin/sdk/manifest.go create mode 100644 plugin/sdk/manifest_schema.json create mode 100644 plugin/sdk/manifest_test.go diff --git a/plugin/sdk/manifest.go b/plugin/sdk/manifest.go new file mode 100644 index 000000000..16dfc0932 --- /dev/null +++ b/plugin/sdk/manifest.go @@ -0,0 +1,121 @@ +// Package sdk hosts the plugin SDK manifest schema and helpers used by +// wfctl to discover plugin capabilities (currently: IaC dispatch version). +// +// The SDK manifest is intentionally additive over [plugin.PluginManifest]; +// it captures only the fields that wfctl reads at apply-time to choose +// between the v1 (legacy in-provider Apply) and v2 (wfctlhelpers.ApplyPlan) +// dispatch paths. +package sdk + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// manifestSchemaJSON is the JSON Schema validating the SDK manifest. It is +// embedded so wfctl always validates against the schema version compiled +// into the binary, not whatever happens to be on disk. +// +//go:embed manifest_schema.json +var manifestSchemaJSON []byte + +// ManifestSchemaJSON returns the raw JSON Schema bytes used to validate +// SDK manifests. Exported for plugin authors and external tooling that +// want to validate plugin.json without depending on this package's +// ParseManifest entry point. +func ManifestSchemaJSON() []byte { + return manifestSchemaJSON +} + +// Manifest captures the SDK-level fields wfctl reads from plugin.json. +// It is a strict subset of the full plugin.PluginManifest — only fields +// that gate apply-time dispatch live here. +type Manifest struct { + // Name is the plugin name. Carried for diagnostics; the SDK schema + // does not enforce shape (lowercase/hyphen rules live in plugin.PluginManifest). + Name string `json:"name"` + + // IaCProvider holds IaC-provider-specific manifest fields. Empty + // (zero-valued) when the plugin does not implement IaCProvider. + IaCProvider IaCProvider `json:"iacProvider"` +} + +// IaCProvider describes IaC-provider-specific manifest fields. +type IaCProvider struct { + // ComputePlanVersion selects the apply-time dispatch path: + // "" (default, treated as "v1"): legacy in-provider Apply switch. + // "v1": explicit legacy dispatch. + // "v2": route through wfctlhelpers.ApplyPlan + // (Replace + input-drift postcondition). + // Schema-validated against the enum ["v1","v2"]; "" passes validation + // because the field is optional. + ComputePlanVersion string `json:"computePlanVersion,omitempty"` +} + +// EffectiveComputePlanVersion returns the dispatch version, defaulting to +// "v1" when the manifest omits the field. Callers should always go through +// this accessor rather than reading ComputePlanVersion directly so the +// default-v1 contract stays in one place. +func (p IaCProvider) EffectiveComputePlanVersion() string { + if p.ComputePlanVersion == "" { + return "v1" + } + return p.ComputePlanVersion +} + +// compiledSchema is the parsed manifest schema. It is compiled lazily on +// first ParseManifest call and cached for the process lifetime; the schema +// is embedded and immutable, so a single compilation is correct. +var compiledSchema *jsonschema.Schema + +// loadSchema compiles manifestSchemaJSON. Returns the compiled schema or +// an error wrapping the underlying compile failure. Separate function so +// failures surface with a clear "schema bug" diagnostic rather than as a +// generic "ParseManifest failed." +func loadSchema() (*jsonschema.Schema, error) { + if compiledSchema != nil { + return compiledSchema, nil + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(manifestSchemaJSON)) + if err != nil { + return nil, fmt.Errorf("sdk manifest schema: unmarshal: %w", err) + } + c := jsonschema.NewCompiler() + if err := c.AddResource("manifest.json", doc); err != nil { + return nil, fmt.Errorf("sdk manifest schema: add resource: %w", err) + } + s, err := c.Compile("manifest.json") + if err != nil { + return nil, fmt.Errorf("sdk manifest schema: compile: %w", err) + } + compiledSchema = s + return s, nil +} + +// ParseManifest validates raw plugin.json bytes against the SDK schema and +// decodes them into a Manifest. Returns an error if the JSON is malformed +// or violates the schema (e.g., iacProvider.computePlanVersion not in +// {"v1","v2"}). Pure-additive: existing plugin.json files without an +// iacProvider key parse cleanly with a zero-valued IaCProvider. +func ParseManifest(data []byte) (*Manifest, error) { + s, err := loadSchema() + if err != nil { + return nil, err + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("manifest: invalid JSON: %w", err) + } + if err := s.Validate(doc); err != nil { + return nil, fmt.Errorf("manifest: schema validation failed: %w", err) + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("manifest: decode: %w", err) + } + return &m, nil +} diff --git a/plugin/sdk/manifest_schema.json b/plugin/sdk/manifest_schema.json new file mode 100644 index 000000000..89ebb365e --- /dev/null +++ b/plugin/sdk/manifest_schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/GoCodeAlone/workflow/plugin/sdk/manifest_schema.json", + "title": "Plugin SDK manifest", + "description": "Schema for the SDK-level plugin manifest (plugin.json) consumed by wfctl. Pure-additive; existing manifests without iacProvider remain valid.", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "iacProvider": { + "type": "object", + "description": "IaC-provider-specific manifest fields. Only present for plugins that implement the IaCProvider interface.", + "properties": { + "computePlanVersion": { + "type": "string", + "description": "Selects the apply-time dispatch path. v1 (default when omitted) routes through the legacy in-provider Apply switch. v2 routes through wfctlhelpers.ApplyPlan with full Replace + drift-postcondition support.", + "enum": ["v1", "v2"] + } + } + } + } +} diff --git a/plugin/sdk/manifest_test.go b/plugin/sdk/manifest_test.go new file mode 100644 index 000000000..a54d87e20 --- /dev/null +++ b/plugin/sdk/manifest_test.go @@ -0,0 +1,44 @@ +package sdk + +import "testing" + +// TestManifest_IaCProvider_ComputePlanVersion exercises the new +// iacProvider.computePlanVersion field. Cases: +// - default-v1: field omitted → EffectiveComputePlanVersion() == "v1" +// - explicit-v1: "v1" → "v1" +// - explicit-v2: "v2" → "v2" +// - rejected: "v3" → ParseManifest returns an error (schema-rejected) +func TestManifest_IaCProvider_ComputePlanVersion(t *testing.T) { + cases := map[string]struct { + in string + want string + wantErr bool + }{ + "default-v1": {`{"name":"x","iacProvider":{}}`, "v1", false}, + "explicit-v1": {`{"name":"x","iacProvider":{"computePlanVersion":"v1"}}`, "v1", false}, + "explicit-v2": {`{"name":"x","iacProvider":{"computePlanVersion":"v2"}}`, "v2", false}, + "rejected": {`{"name":"x","iacProvider":{"computePlanVersion":"v3"}}`, "", true}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + m, err := ParseManifest([]byte(c.in)) + if (err != nil) != c.wantErr { + t.Fatalf("err=%v wantErr=%v", err, c.wantErr) + } + if !c.wantErr && m.IaCProvider.EffectiveComputePlanVersion() != c.want { + t.Errorf("got %q want %q", m.IaCProvider.EffectiveComputePlanVersion(), c.want) + } + }) + } +} + +// TestManifest_IaCProvider_ComputePlanVersion_ZeroValue verifies that an +// IaCProvider with the zero value (empty string) reports v1, matching the +// "default-v1" case but exercising the accessor on a Go-zero-valued struct +// (no JSON involved). +func TestManifest_IaCProvider_ComputePlanVersion_ZeroValue(t *testing.T) { + var p IaCProvider + if got := p.EffectiveComputePlanVersion(); got != "v1" { + t.Errorf("zero IaCProvider.EffectiveComputePlanVersion() = %q, want %q", got, "v1") + } +} From 4df0d1b95db5e00960e0755db5a22a9f304bd861 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 21:03:12 -0400 Subject: [PATCH 14/38] =?UTF-8?q?fix(iac):=20T3.0=20review=20=E2=80=94=20s?= =?UTF-8?q?ync.Once-guarded=20schema=20cache=20+=20tighter=20iacProvider?= =?UTF-8?q?=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-reviewer findings on commit 695a070: - Important: race on lazy compiledSchema cache. Wrap with sync.Once; capture both *jsonschema.Schema and the compile error so concurrent callers observe a single deterministic outcome. Adds a 32-goroutine ParseManifest stress test that fires under -race to lock in the invariant going forward. - Minor: ManifestSchemaJSON() now returns bytes.Clone(...) so callers cannot mutate the //go:embed slice (defense-in-depth; embed slices are technically writable). New test verifies the copy semantics. - Minor: iacProvider sub-object gains additionalProperties:false so a typo like "computeplanversion" or an unknown key is rejected at parse time instead of silently defaulting to v1 dispatch. The root object stays permissive — existing plugin.json files carry version/author/dependencies/etc. and the SDK manifest is a strict subset by design. New test covers both the typo-rejection and the root-permissivity contracts. --- plugin/sdk/manifest.go | 61 +++++++++++++-------- plugin/sdk/manifest_schema.json | 3 +- plugin/sdk/manifest_test.go | 95 ++++++++++++++++++++++++++++++++- 3 files changed, 134 insertions(+), 25 deletions(-) diff --git a/plugin/sdk/manifest.go b/plugin/sdk/manifest.go index 16dfc0932..d9f22bfda 100644 --- a/plugin/sdk/manifest.go +++ b/plugin/sdk/manifest.go @@ -12,6 +12,7 @@ import ( _ "embed" "encoding/json" "fmt" + "sync" "github.com/santhosh-tekuri/jsonschema/v6" ) @@ -27,8 +28,11 @@ var manifestSchemaJSON []byte // SDK manifests. Exported for plugin authors and external tooling that // want to validate plugin.json without depending on this package's // ParseManifest entry point. +// +// Returns a copy so callers cannot mutate the embedded schema; the +// underlying slice from //go:embed is technically writable. func ManifestSchemaJSON() []byte { - return manifestSchemaJSON + return bytes.Clone(manifestSchemaJSON) } // Manifest captures the SDK-level fields wfctl reads from plugin.json. @@ -70,30 +74,41 @@ func (p IaCProvider) EffectiveComputePlanVersion() string { // compiledSchema is the parsed manifest schema. It is compiled lazily on // first ParseManifest call and cached for the process lifetime; the schema // is embedded and immutable, so a single compilation is correct. -var compiledSchema *jsonschema.Schema +// +// The compilation is guarded by sync.Once so concurrent callers cannot race +// on the cache pointer or on the jsonschema compiler's internal state. Both +// the success result and the error are captured so subsequent calls return +// the same outcome without re-compiling. +var ( + compiledSchema *jsonschema.Schema + compiledSchemaErr error + compiledSchemaOnce sync.Once +) -// loadSchema compiles manifestSchemaJSON. Returns the compiled schema or -// an error wrapping the underlying compile failure. Separate function so -// failures surface with a clear "schema bug" diagnostic rather than as a -// generic "ParseManifest failed." +// loadSchema compiles manifestSchemaJSON exactly once per process. Returns +// the compiled schema or an error wrapping the underlying compile failure +// (so failures surface with a clear "schema bug" diagnostic rather than as +// a generic "ParseManifest failed"). func loadSchema() (*jsonschema.Schema, error) { - if compiledSchema != nil { - return compiledSchema, nil - } - doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(manifestSchemaJSON)) - if err != nil { - return nil, fmt.Errorf("sdk manifest schema: unmarshal: %w", err) - } - c := jsonschema.NewCompiler() - if err := c.AddResource("manifest.json", doc); err != nil { - return nil, fmt.Errorf("sdk manifest schema: add resource: %w", err) - } - s, err := c.Compile("manifest.json") - if err != nil { - return nil, fmt.Errorf("sdk manifest schema: compile: %w", err) - } - compiledSchema = s - return s, nil + compiledSchemaOnce.Do(func() { + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(manifestSchemaJSON)) + if err != nil { + compiledSchemaErr = fmt.Errorf("sdk manifest schema: unmarshal: %w", err) + return + } + c := jsonschema.NewCompiler() + if err := c.AddResource("manifest.json", doc); err != nil { + compiledSchemaErr = fmt.Errorf("sdk manifest schema: add resource: %w", err) + return + } + s, err := c.Compile("manifest.json") + if err != nil { + compiledSchemaErr = fmt.Errorf("sdk manifest schema: compile: %w", err) + return + } + compiledSchema = s + }) + return compiledSchema, compiledSchemaErr } // ParseManifest validates raw plugin.json bytes against the SDK schema and diff --git a/plugin/sdk/manifest_schema.json b/plugin/sdk/manifest_schema.json index 89ebb365e..aec5a9767 100644 --- a/plugin/sdk/manifest_schema.json +++ b/plugin/sdk/manifest_schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/GoCodeAlone/workflow/plugin/sdk/manifest_schema.json", "title": "Plugin SDK manifest", - "description": "Schema for the SDK-level plugin manifest (plugin.json) consumed by wfctl. Pure-additive; existing manifests without iacProvider remain valid.", + "description": "Schema for the SDK-level plugin manifest (plugin.json) consumed by wfctl. Pure-additive; existing manifests without iacProvider remain valid. The root object permits additional properties so existing plugin.json files (with name/version/author/etc.) parse cleanly. The iacProvider sub-object is strict so typos like 'computeplanversion' or unknown keys are rejected at parse time, not silently downgraded to v1 dispatch.", "type": "object", "properties": { "name": { @@ -11,6 +11,7 @@ "iacProvider": { "type": "object", "description": "IaC-provider-specific manifest fields. Only present for plugins that implement the IaCProvider interface.", + "additionalProperties": false, "properties": { "computePlanVersion": { "type": "string", diff --git a/plugin/sdk/manifest_test.go b/plugin/sdk/manifest_test.go index a54d87e20..4112e7bd9 100644 --- a/plugin/sdk/manifest_test.go +++ b/plugin/sdk/manifest_test.go @@ -1,6 +1,10 @@ package sdk -import "testing" +import ( + "strings" + "sync" + "testing" +) // TestManifest_IaCProvider_ComputePlanVersion exercises the new // iacProvider.computePlanVersion field. Cases: @@ -42,3 +46,92 @@ func TestManifest_IaCProvider_ComputePlanVersion_ZeroValue(t *testing.T) { t.Errorf("zero IaCProvider.EffectiveComputePlanVersion() = %q, want %q", got, "v1") } } + +// TestManifest_IaCProvider_RejectsTypoKey verifies that a typo inside +// iacProvider (e.g., the lowercase "computeplanversion") is rejected by +// the schema rather than silently parsing to a zero-valued IaCProvider — +// which would produce a silent v1 dispatch downgrade. The root object +// stays permissive so plugin.json files with version/author/etc. still +// parse, but iacProvider is strict by design. +func TestManifest_IaCProvider_RejectsTypoKey(t *testing.T) { + cases := []string{ + `{"name":"x","iacProvider":{"computeplanversion":"v2"}}`, // lowercase typo + `{"name":"x","iacProvider":{"foo":"bar"}}`, // unknown key + } + for _, in := range cases { + t.Run(in, func(t *testing.T) { + _, err := ParseManifest([]byte(in)) + if err == nil { + t.Errorf("expected schema rejection for %q; got nil", in) + } + }) + } +} + +// TestManifest_RootPermitsAdditionalProperties verifies that the root +// object accepts unknown top-level keys so existing plugin.json files +// (which carry version/author/dependencies/etc.) parse cleanly through +// the SDK manifest. Pure-additive contract. +func TestManifest_RootPermitsAdditionalProperties(t *testing.T) { + in := `{"name":"x","version":"1.2.3","author":"jane","description":"hi","iacProvider":{"computePlanVersion":"v2"}}` + m, err := ParseManifest([]byte(in)) + if err != nil { + t.Fatalf("expected pass; got %v", err) + } + if m.IaCProvider.EffectiveComputePlanVersion() != "v2" { + t.Errorf("got %q want %q", m.IaCProvider.EffectiveComputePlanVersion(), "v2") + } +} + +// TestManifestSchemaJSON_ReturnsCopy verifies that mutating the slice +// returned from ManifestSchemaJSON cannot affect the embedded schema +// observed by subsequent callers. +func TestManifestSchemaJSON_ReturnsCopy(t *testing.T) { + a := ManifestSchemaJSON() + if len(a) == 0 { + t.Fatal("ManifestSchemaJSON() returned empty bytes") + } + a[0] = 0xFF // attempt to corrupt + b := ManifestSchemaJSON() + if b[0] == 0xFF { + t.Error("ManifestSchemaJSON returned a shared slice; embedded schema is mutable from callers") + } + if !strings.Contains(string(b), "computePlanVersion") { + t.Error("ManifestSchemaJSON copy lost the schema body") + } +} + +// TestParseManifest_ConcurrentRaceFree exercises the race detector against +// the lazy schema cache: 32 goroutines call ParseManifest simultaneously +// before any sequential call has populated the cache. Run under -race; a +// failure here means the sync.Once guard around compiledSchema regressed. +// +// Note: this test relies on the package-init ordering — Go's testing +// framework gives each test a fresh goroutine but the package globals +// persist across tests in the same binary, so by the time this test runs +// other tests may already have warmed the cache. The test is still useful +// because it stresses concurrent reads of the cached pointer and any +// internal state inside the jsonschema.Schema.Validate path. +func TestParseManifest_ConcurrentRaceFree(t *testing.T) { + const goroutines = 32 + const inputs = 4 + manifests := []string{ + `{"name":"a","iacProvider":{}}`, + `{"name":"b","iacProvider":{"computePlanVersion":"v1"}}`, + `{"name":"c","iacProvider":{"computePlanVersion":"v2"}}`, + `{"name":"d","iacProvider":{"computePlanVersion":"v3"}}`, // expected to error + } + var wg sync.WaitGroup + start := make(chan struct{}) + wg.Add(goroutines) + for i := range goroutines { + go func(idx int) { + defer wg.Done() + <-start // align goroutines for maximum concurrent pressure on loadSchema + in := manifests[idx%inputs] + _, _ = ParseManifest([]byte(in)) + }(i) + } + close(start) + wg.Wait() +} From 503929016822621432ef6359df3f14b368766771 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 19:29:27 -0400 Subject: [PATCH 15/38] =?UTF-8?q?feat(iac):=20add=20refreshoutputs.Refresh?= =?UTF-8?q?=20=E2=80=94=20read-only=20state=20output=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.1 — bounded-concurrency Refresh(ctx, provider, states, opts) that calls ResourceDriver.Read per resource and returns a copy of the state slice with Outputs reconciled to the live values. Default concurrency 8 when Options.Concurrency < 1; otherwise honor the caller's value. On any Read or driver-resolution failure, returns (nil, err) so callers don't half-persist a refresh. Foundation for wfctl infra refresh-outputs (T2.2) and the opt-in apply pre-step (T2.3). Co-Authored-By: Claude Opus 4.7 --- iac/refreshoutputs/refresh.go | 105 ++++++++++++++++++++ iac/refreshoutputs/refresh_test.go | 148 +++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 iac/refreshoutputs/refresh.go create mode 100644 iac/refreshoutputs/refresh_test.go diff --git a/iac/refreshoutputs/refresh.go b/iac/refreshoutputs/refresh.go new file mode 100644 index 000000000..b2087b5e1 --- /dev/null +++ b/iac/refreshoutputs/refresh.go @@ -0,0 +1,105 @@ +// Package refreshoutputs implements read-only state refresh — it reads +// current Outputs from providers and updates the persisted state when fields +// differ. It never invokes Update or Replace at the cloud level; the contract +// is strictly "Read and reconcile in-memory state". +// +// Refresh is the foundation for two consumers in W-2: +// +// - wfctl infra refresh-outputs (T2.2): explicit operator-driven refresh. +// - wfctl infra apply pre-step (T2.3): opt-in via WFCTL_REFRESH_OUTPUTS to +// keep stale outputs from poisoning the planner. +package refreshoutputs + +import ( + "context" + "fmt" + "maps" + "reflect" + "sync" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// defaultConcurrency is the worker count used when Options.Concurrency is +// non-positive. +const defaultConcurrency = 8 + +// Options tunes Refresh behaviour. The zero value is valid and uses +// defaultConcurrency. +type Options struct { + // Concurrency is the maximum number of concurrent Read calls. Values < 1 + // fall back to defaultConcurrency (8). + Concurrency int +} + +// Refresh issues a bounded-concurrency Read against p for each entry in +// states and returns a copy with Outputs reconciled to the live values. +// Resources whose live Outputs are deeply equal to the persisted Outputs are +// returned unchanged (callers can rely on this to skip writes). +// +// Refresh never mutates the input slice. The returned slice is a fresh copy +// of states with possibly-updated Outputs maps; on any Read or +// ResourceDriver failure, Refresh returns (nil, err) and discards partial +// progress so callers don't half-persist a refresh. +// +// Aliasing: for entries whose live Outputs match the persisted Outputs, +// out[i].Outputs is the same map as states[i].Outputs (unchanged maps are +// not cloned). Callers must not mutate Outputs maps in the returned slice +// in place. +func Refresh(ctx context.Context, p interfaces.IaCProvider, states []interfaces.ResourceState, opts Options) ([]interfaces.ResourceState, error) { + if opts.Concurrency < 1 { + opts.Concurrency = defaultConcurrency + } + out := make([]interfaces.ResourceState, len(states)) + copy(out, states) + + sem := make(chan struct{}, opts.Concurrency) + errs := make([]error, len(states)) + var wg sync.WaitGroup + + for i := range states { + sem <- struct{}{} + wg.Go(func() { + defer func() { <-sem }() + errs[i] = refreshOne(ctx, p, &out[i], states[i]) + }) + } + wg.Wait() + + for _, e := range errs { + if e != nil { + return nil, e + } + } + return out, nil +} + +// refreshOne performs a single resource Read and writes the live Outputs +// into dst when they differ from src.Outputs. It returns nil on success or +// the error otherwise. +func refreshOne(ctx context.Context, p interfaces.IaCProvider, dst *interfaces.ResourceState, src interfaces.ResourceState) error { + d, err := p.ResourceDriver(src.Type) + if err != nil { + return err + } + ref := interfaces.ResourceRef{Name: src.Name, Type: src.Type, ProviderID: src.ProviderID} + live, err := d.Read(ctx, ref) + if err != nil { + return fmt.Errorf("could not refresh %q: %w", src.Name, err) + } + if !reflect.DeepEqual(live.Outputs, src.Outputs) { + dst.Outputs = cloneMap(live.Outputs) + } + return nil +} + +// cloneMap returns an independent shallow copy of m. Callers receive a map +// they can mutate without aliasing the live driver output. +func cloneMap(m map[string]any) map[string]any { + if m == nil { + return nil + } + c := make(map[string]any, len(m)) + maps.Copy(c, m) + return c +} diff --git a/iac/refreshoutputs/refresh_test.go b/iac/refreshoutputs/refresh_test.go new file mode 100644 index 000000000..7e5d66e98 --- /dev/null +++ b/iac/refreshoutputs/refresh_test.go @@ -0,0 +1,148 @@ +package refreshoutputs + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// fakeIaCProvider is a minimal IaCProvider stub that returns canned +// ResourceOutput values for Read via fakeResourceDriver. It only implements +// the methods that Refresh exercises (ResourceDriver); the rest panic to +// make accidental use during testing obvious. +type fakeIaCProvider struct { + // readOutputs maps ProviderID → fake driver output Outputs map. nil + // entries produce a ResourceOutput with empty Outputs. + readOutputs map[string]map[string]any + // readErr, when non-nil, causes the driver Read to return the error + // regardless of the resource ref. + readErr error +} + +func (f *fakeIaCProvider) Name() string { panic("not used") } +func (f *fakeIaCProvider) Version() string { panic("not used") } +func (f *fakeIaCProvider) Initialize(context.Context, map[string]any) error { + panic("not used") +} +func (f *fakeIaCProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { + panic("not used") +} +func (f *fakeIaCProvider) Plan(context.Context, []interfaces.ResourceSpec, []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + panic("not used") +} +func (f *fakeIaCProvider) Apply(context.Context, *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + panic("not used") +} +func (f *fakeIaCProvider) Destroy(context.Context, []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + panic("not used") +} +func (f *fakeIaCProvider) Status(context.Context, []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + panic("not used") +} +func (f *fakeIaCProvider) DetectDrift(context.Context, []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + panic("not used") +} +func (f *fakeIaCProvider) Import(context.Context, string, string) (*interfaces.ResourceState, error) { + panic("not used") +} +func (f *fakeIaCProvider) ResolveSizing(string, interfaces.Size, *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + panic("not used") +} +func (f *fakeIaCProvider) SupportedCanonicalKeys() []string { panic("not used") } +func (f *fakeIaCProvider) BootstrapStateBackend(context.Context, map[string]any) (*interfaces.BootstrapResult, error) { + panic("not used") +} +func (f *fakeIaCProvider) Close() error { return nil } + +func (f *fakeIaCProvider) ResourceDriver(string) (interfaces.ResourceDriver, error) { + return &fakeResourceDriver{provider: f}, nil +} + +// fakeResourceDriver answers Read from the parent fakeIaCProvider's +// readOutputs map. All other methods panic to make misuse loud. +type fakeResourceDriver struct { + provider *fakeIaCProvider +} + +func (d *fakeResourceDriver) Create(context.Context, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *fakeResourceDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + if d.provider.readErr != nil { + return nil, d.provider.readErr + } + out := d.provider.readOutputs[ref.ProviderID] + return &interfaces.ResourceOutput{ + Name: ref.Name, + Type: ref.Type, + ProviderID: ref.ProviderID, + Outputs: out, + }, nil +} +func (d *fakeResourceDriver) Update(context.Context, interfaces.ResourceRef, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *fakeResourceDriver) Delete(context.Context, interfaces.ResourceRef) error { + panic("not used") +} +func (d *fakeResourceDriver) Diff(context.Context, interfaces.ResourceSpec, *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + panic("not used") +} +func (d *fakeResourceDriver) HealthCheck(context.Context, interfaces.ResourceRef) (*interfaces.HealthResult, error) { + panic("not used") +} +func (d *fakeResourceDriver) Scale(context.Context, interfaces.ResourceRef, int) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *fakeResourceDriver) SensitiveKeys() []string { return nil } + +func mapsEqual(a, b map[string]any) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + bv, ok := b[k] + if !ok || bv != v { + return false + } + } + return true +} + +func TestRefreshOutputs_ReadsEachResource_PersistsChangedOnly(t *testing.T) { + states := []interfaces.ResourceState{ + {Name: "vpc-1", Type: "infra.vpc", ProviderID: "uuid-1", Outputs: map[string]any{"ip_range": "10.0.0.0/16"}}, + {Name: "vpc-2", Type: "infra.vpc", ProviderID: "uuid-2", Outputs: map[string]any{"ip_range": "10.1.0.0/16"}}, + } + fakeProvider := &fakeIaCProvider{readOutputs: map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, // new "id" field + "uuid-2": {"ip_range": "10.1.0.0/16"}, // unchanged + }} + refreshed, err := Refresh(context.Background(), fakeProvider, states, Options{Concurrency: 2}) + if err != nil { + t.Fatal(err) + } + if got := refreshed[0].Outputs["id"]; got != "uuid-1" { + t.Errorf("vpc-1 should have new 'id' output: %v", refreshed[0].Outputs) + } + if !mapsEqual(refreshed[1].Outputs, states[1].Outputs) { + t.Errorf("vpc-2 should be unchanged: %v vs %v", refreshed[1].Outputs, states[1].Outputs) + } +} + +func TestRefreshOutputs_PartialFailure_ReturnsError(t *testing.T) { + states := []interfaces.ResourceState{ + {Name: "vpc-1", Type: "infra.vpc", ProviderID: "uuid-1"}, + } + fakeProvider := &fakeIaCProvider{readErr: errors.New("network failure")} + _, err := Refresh(context.Background(), fakeProvider, states, Options{Concurrency: 1}) + if err == nil { + t.Fatalf("expected error on Read failure") + } + if !strings.Contains(err.Error(), "could not refresh") { + t.Errorf("error should mention 'could not refresh'; got: %v", err) + } +} From dcc1ec2bb96cd7724381d7ea4fe2d93410bd4e26 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 20:01:53 -0400 Subject: [PATCH 16/38] feat(iac): add wfctl infra refresh-outputs subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.2 — `wfctl infra refresh-outputs [-c CONFIG] [--env ENV] [--concurrency N]` reads live Outputs for each resource already in state and persists any field-level changes back to the state backend. Read-only at the cloud level — never invokes Update or Replace. Discovers iac.provider modules in the config (with per-env resolution), groups state entries by their owning iac.provider module (ProviderRef-first, falling back to provider type when exactly one module of that type exists), loads each provider once, calls iac/refreshoutputs.Refresh per group, and SaveResource()s any state whose Outputs map changed. When the resolved config has no usable iac.provider module for the requested env, emits the literal error refresh-outputs: provider not configured for env "" verbatim per `fmt.Errorf("refresh-outputs: provider not configured for env %q", env)`. T2.7's runtime-launch-validation asserts against this exact line. Co-Authored-By: Claude Opus 4.7 --- cmd/wfctl/infra.go | 3 + cmd/wfctl/infra_refresh_outputs.go | 244 +++++++++++++++++ cmd/wfctl/infra_refresh_outputs_test.go | 337 ++++++++++++++++++++++++ 3 files changed, 584 insertions(+) create mode 100644 cmd/wfctl/infra_refresh_outputs.go create mode 100644 cmd/wfctl/infra_refresh_outputs_test.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index 7582baba6..b1efd0528 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -46,6 +46,8 @@ func runInfra(args []string) error { return runInfraBootstrap(args[1:]) case "outputs": return runInfraOutputs(args[1:]) + case "refresh-outputs": + return runInfraRefreshOutputs(args[1:]) case "align": return runInfraAlign(args[1:]) case "security-check": @@ -69,6 +71,7 @@ Actions: import Import an existing cloud resource into state state Manage IaC state (list, export, import) outputs Print captured resource outputs from state + refresh-outputs Read live outputs and reconcile state (no cloud writes) align Validate IaC config + plan alignment (8 rule families) security-check Scan plan.json for security policy violations diff --git a/cmd/wfctl/infra_refresh_outputs.go b/cmd/wfctl/infra_refresh_outputs.go new file mode 100644 index 000000000..fdd889138 --- /dev/null +++ b/cmd/wfctl/infra_refresh_outputs.go @@ -0,0 +1,244 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "reflect" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/refreshoutputs" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// runInfraRefreshOutputs reads live Outputs from each provider for the +// resources already in state and persists any field-level changes back to +// the state backend. The contract is strictly read-only at the cloud level — +// no Update or Replace is ever invoked. See iac/refreshoutputs/refresh.go +// for the helper this command wraps. +// +// When the resolved config has no usable iac.provider module for the +// requested env, the literal error +// +// refresh-outputs: provider not configured for env "" +// +// is returned so that operators can distinguish a misconfigured workflow +// from a transient cloud-side failure. T2.7 asserts against this exact line. +func runInfraRefreshOutputs(args []string) error { + fs := flag.NewFlagSet("infra refresh-outputs", flag.ContinueOnError) + // Direct help/usage to stdout so `--help` is pipeable and the CI + // runtime-launch validator (T2.7) can capture it via captureStdout. + fs.SetOutput(os.Stdout) + var configFile, envName string + var concurrency int + fs.StringVar(&configFile, "config", "", "Config file") + fs.StringVar(&configFile, "c", "", "Config file (short for --config)") + fs.StringVar(&envName, "env", "", "Environment name (resolves per-module overrides)") + fs.StringVar(&envName, "e", "", "Environment name (short for --env)") + fs.IntVar(&concurrency, "concurrency", 0, "Maximum concurrent Read calls (default 8)") + if err := fs.Parse(args); err != nil { + return err + } + + cfgFile, err := resolveInfraConfig(fs, configFile) + if err != nil { + return err + } + + ctx := context.Background() + + providerDefs, err := discoverIaCProvidersForRefresh(cfgFile, envName) + if err != nil { + return err + } + if len(providerDefs) == 0 { + return fmt.Errorf("refresh-outputs: provider not configured for env %q", envName) + } + + states, err := loadCurrentState(cfgFile, envName) + if err != nil { + return fmt.Errorf("load current state: %w", err) + } + if len(states) == 0 { + fmt.Println("Refresh: no state to refresh.") + return nil + } + + store, err := resolveStateStore(cfgFile, envName) + if err != nil { + return fmt.Errorf("open state store: %w", err) + } + + return refreshOutputsAcrossProviders(ctx, providerDefs, states, store, concurrency, os.Stdout) +} + +// refreshOutputsProviderDef captures everything refresh-outputs needs to +// load and call a single iac.provider module: its module name, the provider +// type, and the env-resolved config. +type refreshOutputsProviderDef struct { + moduleName string + provType string + provCfg map[string]any +} + +// discoverIaCProvidersForRefresh walks cfgFile's modules and returns one +// providerDef per iac.provider module that resolves successfully for envName +// (modules disabled with `environments: { : ~ }` are skipped). When +// envName is "", the top-level module config is used as-is. The returned +// slice preserves declaration order. +func discoverIaCProvidersForRefresh(cfgFile, envName string) ([]refreshOutputsProviderDef, error) { + cfg, err := config.LoadFromFile(cfgFile) + if err != nil { + return nil, fmt.Errorf("load config: %w", err) + } + var defs []refreshOutputsProviderDef + for i := range cfg.Modules { + m := &cfg.Modules[i] + if m.Type != "iac.provider" { + continue + } + var modCfg map[string]any + if envName != "" { + resolved, ok := m.ResolveForEnv(envName) + if !ok { + // Disabled for this env via null override; not "no provider" + // for the env unless every iac.provider is disabled. + continue + } + modCfg = config.ExpandEnvInMap(resolved.Config) + } else { + modCfg = config.ExpandEnvInMap(m.Config) + } + pt, _ := modCfg["provider"].(string) + if pt == "" { + fmt.Fprintf(os.Stderr, "warning: iac.provider %q has no 'provider' field; skipping\n", m.Name) + continue + } + defs = append(defs, refreshOutputsProviderDef{ + moduleName: m.Name, + provType: pt, + provCfg: modCfg, + }) + } + return defs, nil +} + +// refreshOutputsAcrossProviders groups state entries by which iac.provider +// module owns them, calls refreshoutputs.Refresh for each group, and +// persists any state entries whose Outputs changed. It loads each provider +// at most once. +// +// State entries with a non-empty ProviderRef are matched to the iac.provider +// module of the same name. State entries without a ProviderRef fall back to +// the iac.provider module whose provider type matches state.Provider, but +// only when exactly one such module exists; otherwise the fallback is +// ambiguous and the entry is skipped with a warning rather than refreshed +// against the wrong provider. +func refreshOutputsAcrossProviders( + ctx context.Context, + providerDefs []refreshOutputsProviderDef, + states []interfaces.ResourceState, + store infraStateStore, + concurrency int, + stdout io.Writer, +) error { + defByName := make(map[string]refreshOutputsProviderDef, len(providerDefs)) + defsByType := make(map[string][]string) + for _, d := range providerDefs { + defByName[d.moduleName] = d + defsByType[d.provType] = append(defsByType[d.provType], d.moduleName) + } + + groups := make(map[string][]int) // moduleName → indices into states + var groupOrder []string + for i := range states { + s := &states[i] + moduleName := s.ProviderRef + if moduleName == "" && s.Provider != "" { + candidates := defsByType[s.Provider] + if len(candidates) == 1 { + moduleName = candidates[0] + } + } + if moduleName == "" { + fmt.Fprintf(stdout, "Refresh: skipping %q — cannot resolve owning provider (provider_ref=%q, provider=%q)\n", + s.Name, s.ProviderRef, s.Provider) + continue + } + if _, ok := defByName[moduleName]; !ok { + fmt.Fprintf(stdout, "Refresh: skipping %q — provider module %q not declared in config\n", s.Name, moduleName) + continue + } + if _, exists := groups[moduleName]; !exists { + groupOrder = append(groupOrder, moduleName) + } + groups[moduleName] = append(groups[moduleName], i) + } + + updated := 0 + for _, moduleName := range groupOrder { + def := defByName[moduleName] + idxs := groups[moduleName] + groupStates := make([]interfaces.ResourceState, len(idxs)) + for j, idx := range idxs { + groupStates[j] = states[idx] + } + fmt.Fprintf(stdout, "Refresh: reading %d resource(s) via provider %q (%s)...\n", + len(groupStates), moduleName, def.provType) + if err := refreshOneProviderGroup(ctx, def, idxs, groupStates, states, store, concurrency, &updated, stdout); err != nil { + return err + } + } + fmt.Fprintf(stdout, "Refresh: complete — %d resource(s) updated.\n", updated) + return nil +} + +// refreshOneProviderGroup loads a single provider, refreshes its state +// subset, and persists any entries whose Outputs changed. Extracted so the +// closer is `defer`-closed for panic safety and to keep +// refreshOutputsAcrossProviders readable. +func refreshOneProviderGroup( + ctx context.Context, + def refreshOutputsProviderDef, + idxs []int, + groupStates []interfaces.ResourceState, + states []interfaces.ResourceState, + store infraStateStore, + concurrency int, + updated *int, + stdout io.Writer, +) error { + provider, closer, err := resolveIaCProvider(ctx, def.provType, def.provCfg) + if err != nil { + return fmt.Errorf("provider %q (%s): load provider: %w", def.moduleName, def.provType, err) + } + if closer != nil { + defer func() { + if cerr := closer.Close(); cerr != nil { + fmt.Fprintf(os.Stderr, "warning: provider %q shutdown: %v\n", def.provType, cerr) + } + }() + } + refreshed, err := refreshoutputs.Refresh(ctx, provider, groupStates, refreshoutputs.Options{Concurrency: concurrency}) + if err != nil { + return fmt.Errorf("provider %q: %w", def.moduleName, err) + } + for j, idx := range idxs { + fresh := refreshed[j] + // reflect.DeepEqual handles non-comparable nested values + // (slices, maps, structs) that the Outputs maps can carry from + // real cloud APIs. A `==` compare on `any` panics for those. + if reflect.DeepEqual(states[idx].Outputs, fresh.Outputs) { + continue + } + states[idx] = fresh + if err := store.SaveResource(ctx, fresh); err != nil { + return fmt.Errorf("provider %q: persist refreshed %q: %w", def.moduleName, fresh.Name, err) + } + *updated++ + fmt.Fprintf(stdout, "Refresh: updated %s\n", fresh.Name) + } + return nil +} diff --git a/cmd/wfctl/infra_refresh_outputs_test.go b/cmd/wfctl/infra_refresh_outputs_test.go new file mode 100644 index 000000000..65e1acf1a --- /dev/null +++ b/cmd/wfctl/infra_refresh_outputs_test.go @@ -0,0 +1,337 @@ +package main + +import ( + "context" + "errors" + "flag" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// refreshOutputsCmdFakeProvider is the IaCProvider stub used by the +// refresh-outputs subcommand tests. It only needs to answer ResourceDriver → +// fakeResourceDriver.Read with canned outputs; everything else can be a +// safe no-op because the subcommand never calls them on the read-only path. +type refreshOutputsCmdFakeProvider struct { + readOutputs map[string]map[string]any +} + +func (f *refreshOutputsCmdFakeProvider) Name() string { return "fake-refresh-outputs" } +func (f *refreshOutputsCmdFakeProvider) Version() string { return "0.0.0" } +func (f *refreshOutputsCmdFakeProvider) Initialize(_ context.Context, _ map[string]any) error { + return nil +} +func (f *refreshOutputsCmdFakeProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { + return nil +} +func (f *refreshOutputsCmdFakeProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + return &interfaces.IaCPlan{}, nil +} +func (f *refreshOutputsCmdFakeProvider) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + return &interfaces.ApplyResult{}, nil +} +func (f *refreshOutputsCmdFakeProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return &refreshOutputsCmdFakeDriver{outputs: f.readOutputs}, nil +} +func (f *refreshOutputsCmdFakeProvider) SupportedCanonicalKeys() []string { return nil } +func (f *refreshOutputsCmdFakeProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { + return nil, nil +} +func (f *refreshOutputsCmdFakeProvider) Close() error { return nil } + +// refreshOutputsCmdFakeDriver answers Read with canned outputs keyed by +// ProviderID. All other ResourceDriver methods panic to make accidental +// non-Read use loud during testing. +type refreshOutputsCmdFakeDriver struct { + outputs map[string]map[string]any +} + +func (d *refreshOutputsCmdFakeDriver) Create(context.Context, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + return &interfaces.ResourceOutput{ + Name: ref.Name, + Type: ref.Type, + ProviderID: ref.ProviderID, + Outputs: d.outputs[ref.ProviderID], + }, nil +} +func (d *refreshOutputsCmdFakeDriver) Update(context.Context, interfaces.ResourceRef, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) Delete(context.Context, interfaces.ResourceRef) error { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) Diff(context.Context, interfaces.ResourceSpec, *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) HealthCheck(context.Context, interfaces.ResourceRef) (*interfaces.HealthResult, error) { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) Scale(context.Context, interfaces.ResourceRef, int) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *refreshOutputsCmdFakeDriver) SensitiveKeys() []string { return nil } + +// TestRefreshOutputs_CommandHelp verifies that --help prints the standard +// FlagSet usage banner naming the subcommand. The banner is what T2.7's +// runtime-launch validation will exercise via the built binary. +func TestRefreshOutputs_CommandHelp(t *testing.T) { + out, err := captureStdout(t, func() error { + return runInfraRefreshOutputs([]string{"--help"}) + }) + if !errors.Is(err, flag.ErrHelp) { + t.Fatalf("expected flag.ErrHelp, got %v", err) + } + if !strings.Contains(out, "Usage of infra refresh-outputs") { + t.Errorf("help output missing 'Usage of infra refresh-outputs'; got: %s", out) + } +} + +// TestRefreshOutputs_PersistsNewFieldsToState exercises the end-to-end +// persists-new-output path: pre-populate a filesystem state backend with a +// stale VPC entry, swap the IaCProvider loader for a stub that returns an +// extra "id" field on Read, run the subcommand, and verify the field has +// been written back to state. +func TestRefreshOutputs_PersistsNewFieldsToState(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + + cfgPath := filepath.Join(dir, "infra.yaml") + cfg := `modules: + - name: cloud-provider + type: iac.provider + config: + provider: fake-provider + - name: state-store + type: iac.state + config: + backend: filesystem + directory: ` + stateDir + ` + - name: coredump-staging-vpc + type: infra.vpc + config: + provider: cloud-provider +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + + // Pre-populate state with a stale VPC entry (no "id" output yet). + store, err := resolveStateStore(cfgPath, "") + if err != nil { + t.Fatalf("resolveStateStore: %v", err) + } + stale := interfaces.ResourceState{ + ID: "coredump-staging-vpc", + Name: "coredump-staging-vpc", + Type: "infra.vpc", + Provider: "fake-provider", + ProviderRef: "cloud-provider", + ProviderID: "uuid-1", + Outputs: map[string]any{"ip_range": "10.0.0.0/16"}, + } + if err := store.SaveResource(context.Background(), stale); err != nil { + t.Fatalf("seed state: %v", err) + } + + // Swap the provider loader so the test never touches a real cloud. + orig := resolveIaCProvider + defer func() { resolveIaCProvider = orig }() + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return &refreshOutputsCmdFakeProvider{ + readOutputs: map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, + }, + }, nil, nil + } + + if _, err := captureStdout(t, func() error { + return runInfraRefreshOutputs([]string{"-c", cfgPath, "--env", "staging"}) + }); err != nil { + t.Fatalf("runInfraRefreshOutputs: %v", err) + } + + refreshed, err := loadCurrentState(cfgPath, "") + if err != nil { + t.Fatalf("loadCurrentState: %v", err) + } + byName := make(map[string]interfaces.ResourceState, len(refreshed)) + for _, s := range refreshed { + byName[s.Name] = s + } + got := byName["coredump-staging-vpc"] + if id, _ := got.Outputs["id"].(string); id != "uuid-1" { + t.Errorf("expected 'id' field after refresh, got %v", got.Outputs) + } +} + +// TestRefreshOutputs_NonComparableOutputs_DoesNotPanic regression-tests +// the Outputs-changed check against real-world Outputs that contain slices +// and nested maps. A naive `lv != v` on `any` values panics with +// "comparing uncomparable type" the moment a slice or map is involved — +// reflect.DeepEqual is the correct tool. Covers both the unchanged path +// (live == persisted) and the changed path (live grows a new field) so +// neither code direction can regress to the panic. +func TestRefreshOutputs_NonComparableOutputs_DoesNotPanic(t *testing.T) { + for _, tc := range []struct { + name string + liveOutputs map[string]any + expectUpdate bool + }{ + { + name: "unchanged-with-slice-and-nested-map", + liveOutputs: map[string]any{ + "subnet_ids": []string{"a", "b"}, + "tags": map[string]any{"env": "staging"}, + }, + expectUpdate: false, + }, + { + name: "added-field-with-slice-and-nested-map", + liveOutputs: map[string]any{ + "subnet_ids": []string{"a", "b"}, + "tags": map[string]any{"env": "staging"}, + "id": "uuid-1", + }, + expectUpdate: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dir, "infra.yaml") + cfg := `modules: + - name: cloud-provider + type: iac.provider + config: + provider: fake-provider + - name: state-store + type: iac.state + config: + backend: filesystem + directory: ` + stateDir + ` + - name: vpc-1 + type: infra.vpc + config: + provider: cloud-provider +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + + store, err := resolveStateStore(cfgPath, "") + if err != nil { + t.Fatalf("resolveStateStore: %v", err) + } + persisted := interfaces.ResourceState{ + ID: "vpc-1", + Name: "vpc-1", + Type: "infra.vpc", + Provider: "fake-provider", + ProviderRef: "cloud-provider", + ProviderID: "uuid-1", + Outputs: map[string]any{ + "subnet_ids": []string{"a", "b"}, + "tags": map[string]any{"env": "staging"}, + }, + } + if err := store.SaveResource(context.Background(), persisted); err != nil { + t.Fatalf("seed state: %v", err) + } + + orig := resolveIaCProvider + defer func() { resolveIaCProvider = orig }() + liveOutputs := tc.liveOutputs + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return &refreshOutputsCmdFakeProvider{ + readOutputs: map[string]map[string]any{"uuid-1": liveOutputs}, + }, nil, nil + } + + // The critical assertion: this call must NOT panic with + // "comparing uncomparable type". + if _, err := captureStdout(t, func() error { + return runInfraRefreshOutputs([]string{"-c", cfgPath}) + }); err != nil { + t.Fatalf("runInfraRefreshOutputs panicked or errored: %v", err) + } + + refreshed, err := loadCurrentState(cfgPath, "") + if err != nil { + t.Fatalf("loadCurrentState: %v", err) + } + var got interfaces.ResourceState + for _, s := range refreshed { + if s.Name == "vpc-1" { + got = s + break + } + } + if tc.expectUpdate { + if _, ok := got.Outputs["id"]; !ok { + t.Errorf("expected new 'id' field after refresh; got Outputs=%v", got.Outputs) + } + } + // Whether updated or not, post-state must still carry the + // nested values intact. + if _, ok := got.Outputs["subnet_ids"]; !ok { + t.Errorf("expected subnet_ids preserved; got Outputs=%v", got.Outputs) + } + }) + } +} + +// TestRefreshOutputs_NoProviderConfigured_ReturnsLiteralError pins the +// exact stderr line T2.7 asserts against. The wording is load-bearing — +// changing it breaks the runtime-launch-validation gate. +func TestRefreshOutputs_NoProviderConfigured_ReturnsLiteralError(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + // Config has no iac.provider modules. + cfg := `modules: + - name: state-store + type: iac.state + config: + backend: filesystem + directory: ` + dir + ` +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + err := runInfraRefreshOutputs([]string{"-c", cfgPath, "--env", "staging"}) + if err == nil { + t.Fatal("expected error when no iac.provider configured") + } + want := `refresh-outputs: provider not configured for env "staging"` + if err.Error() != want { + t.Errorf("error mismatch:\n got: %q\nwant: %q", err.Error(), want) + } +} From 207924d32e82f1117f520764538431a205ce9997 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 20:19:01 -0400 Subject: [PATCH 17/38] feat(iac): apply-time refresh-outputs pre-step (opt-in via WFCTL_REFRESH_OUTPUTS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.3 — wires iac/refreshoutputs.Refresh into runInfraApply as a pre-plan read-only state reconciliation. Default OFF: operators get pre-W-2 behavior unless they explicitly opt in. Activation rules: - WFCTL_REFRESH_OUTPUTS unset, empty, or unrecognised → no-op (default). - WFCTL_REFRESH_OUTPUTS="1"/"true"/"t" (strconv.ParseBool truthy) → run pre-step. - WFCTL_REFRESH_OUTPUTS="0"/"false"/"f" (strconv.ParseBool falsey) → no-op. Operators who use the "0"/"false" convention to disable a feature get the expected behaviour rather than a presence-only foot-gun. - --skip-refresh → suppress pre-step regardless of env var (for CI environments that force the env var on globally). Behavior: after the existing --refresh drift/prune phase and before the plan/apply dispatch, discovers iac.provider modules with per-env resolution, loads current state, and calls refreshOutputsAcrossProviders to read live Outputs and persist any field-level changes. On any Read or driver-resolution failure, apply aborts with the wrapped error from T2.1's helper (no half-persisted refresh, no plan computed against stale state). Only fires for infra.* configs (legacy platform.* path is silently skipped). Rollback: unset WFCTL_REFRESH_OUTPUTS, pass --skip-refresh, or revert this commit. Reverting removes the pre-step entirely (helper file plus the gated block in infra.go). Co-Authored-By: Claude Opus 4.7 --- cmd/wfctl/infra.go | 14 ++ cmd/wfctl/infra_apply_refresh_pre.go | 99 ++++++++++ cmd/wfctl/infra_apply_refresh_pre_test.go | 210 ++++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 cmd/wfctl/infra_apply_refresh_pre.go create mode 100644 cmd/wfctl/infra_apply_refresh_pre_test.go diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index b1efd0528..45129b262 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -975,6 +975,8 @@ func runInfraApply(args []string) error { fs.BoolVar(&refreshFlag, "refresh", false, "Detect drift and prune ghost-in-state entries before applying") var allowProtectedPruneFlag bool fs.BoolVar(&allowProtectedPruneFlag, "allow-protected-prune", false, "Allow pruning state entries for resources marked protected: true (requires --refresh)") + var skipRefreshFlag bool + fs.BoolVar(&skipRefreshFlag, "skip-refresh", false, "Skip the WFCTL_REFRESH_OUTPUTS pre-step refresh even if the env var is set") autoApprove := &autoApproveVal showSensitive := showSensitiveVal if err := fs.Parse(args); err != nil { @@ -1077,6 +1079,18 @@ func runInfraApply(args []string) error { } } + // WFCTL_REFRESH_OUTPUTS pre-step (T2.3): when opted in, read live + // Outputs from each provider and persist any field-level changes + // before computing the plan, so apply doesn't make decisions on + // stale state. Default off; --skip-refresh always wins. Only + // applicable for infra.* configs (legacy platform.* path doesn't + // flow through iac/refreshoutputs). + if applyPreStepRefreshEnabled(skipRefreshFlag) && hasInfraModules(cfgFile) { + if err := applyPreStepRefreshOutputs(ctx, cfgFile, envName, os.Stdout); err != nil { + return fmt.Errorf("apply pre-step refresh-outputs: %w", err) + } + } + fmt.Printf("Applying infrastructure from %s...\n", cfgFile) // --plan: dispatch actions from a pre-emitted plan file, skipping ComputePlan. diff --git a/cmd/wfctl/infra_apply_refresh_pre.go b/cmd/wfctl/infra_apply_refresh_pre.go new file mode 100644 index 000000000..b5c63d466 --- /dev/null +++ b/cmd/wfctl/infra_apply_refresh_pre.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "strconv" +) + +// applyPreStepRefreshOutputsEnvVar is the environment variable that opts in +// to running iac/refreshoutputs.Refresh as a pre-step before apply computes +// its plan. The default is OFF so apply behaves identically to pre-W-2 for +// every operator who doesn't set it. +// +// The value is parsed with strconv.ParseBool: "1", "t", "T", "TRUE", +// "true", "True" enable; "0", "f", "F", "FALSE", "false", "False" disable; +// unset, empty, or unrecognised values disable. Operators who use the +// "0" / "false" convention to turn features off therefore get the +// expected behaviour rather than the off-by-one foot-gun a presence-only +// toggle would produce. +const applyPreStepRefreshOutputsEnvVar = "WFCTL_REFRESH_OUTPUTS" + +// applyPreStepRefreshOutputs runs the read-only output refresh against +// every iac.provider declared in cfgFile and persists any state entries +// whose Outputs changed. It is the apply-time counterpart to wfctl infra +// refresh-outputs (T2.2): same helpers, same error semantics. On any Read +// or driver-resolution failure, it returns the wrapped error so apply +// aborts before computing a plan against stale outputs. +// +// This function exists as its own file so reverting commit +// "feat(iac): apply-time refresh-outputs pre-step ..." removes the entire +// code path in one operation. The caller in runInfraApply gates this on +// three conditions, all of which must be true for the pre-step to fire: +// +// - applyPreStepRefreshEnabled returns true (WFCTL_REFRESH_OUTPUTS +// parses to true and --skip-refresh was not passed). +// - hasInfraModules(cfgFile) is true (legacy platform.* configs are +// skipped — they don't flow through iac/refreshoutputs). +// +// The helper itself is purely additive on top of those gates: it never +// short-circuits on values it would otherwise accept, so a caller that +// has already decided to refresh can rely on the helper to do exactly +// that. +func applyPreStepRefreshOutputs(ctx context.Context, cfgFile, envName string, stdout io.Writer) error { + providerDefs, err := discoverIaCProvidersForRefresh(cfgFile, envName) + if err != nil { + return err + } + if len(providerDefs) == 0 { + // No provider for this env — log and return nil rather than + // aborting apply: the downstream applyInfraModules call will + // produce a more actionable error if a provider was actually + // expected. The log line makes the no-op visible to operators + // who explicitly opted in. + fmt.Fprintln(stdout, "Refresh pre-step: no iac.provider modules in config; skipping.") + return nil + } + + states, err := loadCurrentState(cfgFile, envName) + if err != nil { + return fmt.Errorf("load current state: %w", err) + } + if len(states) == 0 { + return nil + } + + store, err := resolveStateStore(cfgFile, envName) + if err != nil { + return fmt.Errorf("open state store: %w", err) + } + + fmt.Fprintln(stdout, "Refreshing outputs from cloud (read-only)...") + return refreshOutputsAcrossProviders(ctx, providerDefs, states, store, 0 /* default concurrency */, stdout) +} + +// applyPreStepRefreshEnabled reports whether the opt-in env var parses +// to true AND --skip-refresh was not passed. The flag always wins so +// operators can disable the pre-step in environments where the env var +// is forced on globally (e.g. CI that exports it for every job). +// +// Empty/unset and unrecognised values both disable: the env var is +// strictly opt-in, never opt-out. "0" / "false" therefore disable +// rather than (mis-)enabling, which is the convention every operator +// expects. +func applyPreStepRefreshEnabled(skipRefreshFlag bool) bool { + if skipRefreshFlag { + return false + } + v := os.Getenv(applyPreStepRefreshOutputsEnvVar) + if v == "" { + return false + } + enabled, err := strconv.ParseBool(v) + if err != nil { + return false + } + return enabled +} diff --git a/cmd/wfctl/infra_apply_refresh_pre_test.go b/cmd/wfctl/infra_apply_refresh_pre_test.go new file mode 100644 index 000000000..4a2406d49 --- /dev/null +++ b/cmd/wfctl/infra_apply_refresh_pre_test.go @@ -0,0 +1,210 @@ +package main + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// writeApplyPreRefreshConfig writes an infra YAML at /infra.yaml with +// auto_bootstrap disabled (so the apply path doesn't try to run bootstrap), +// a filesystem state backend pointed at /state, a single iac.provider +// module, and one infra.vpc resource keyed to it. Returned path is the +// absolute config path. +func writeApplyPreRefreshConfig(t *testing.T, dir string) string { + t.Helper() + stateDir := filepath.Join(dir, "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dir, "infra.yaml") + cfg := `infra: + auto_bootstrap: false +modules: + - name: cloud-provider + type: iac.provider + config: + provider: fake-provider + - name: state-store + type: iac.state + config: + backend: filesystem + directory: ` + stateDir + ` + - name: coredump-staging-vpc + type: infra.vpc + config: + provider: cloud-provider +` + if err := os.WriteFile(cfgPath, []byte(cfg), 0o600); err != nil { + t.Fatal(err) + } + return cfgPath +} + +// seedStaleVPCState writes a single VPC ResourceState to the configured +// filesystem state backend with no "id" output, ready for refresh to add it. +func seedStaleVPCState(t *testing.T, cfgPath string) { + t.Helper() + store, err := resolveStateStore(cfgPath, "") + if err != nil { + t.Fatalf("resolveStateStore: %v", err) + } + stale := interfaces.ResourceState{ + ID: "coredump-staging-vpc", + Name: "coredump-staging-vpc", + Type: "infra.vpc", + Provider: "fake-provider", + ProviderRef: "cloud-provider", + ProviderID: "uuid-1", + Outputs: map[string]any{"ip_range": "10.0.0.0/16"}, + } + if err := store.SaveResource(context.Background(), stale); err != nil { + t.Fatalf("seed state: %v", err) + } +} + +// loadVPCStateOutputs returns the Outputs map for coredump-staging-vpc as +// persisted on disk after the apply call returns. +func loadVPCStateOutputs(t *testing.T, cfgPath string) map[string]any { + t.Helper() + states, err := loadCurrentState(cfgPath, "") + if err != nil { + t.Fatalf("loadCurrentState: %v", err) + } + for _, s := range states { + if s.Name == "coredump-staging-vpc" { + return s.Outputs + } + } + t.Fatalf("coredump-staging-vpc not in state after apply; got %d entries", len(states)) + return nil +} + +// installFakeRefreshProvider swaps the global resolveIaCProvider for the +// duration of the test with a stub that handles both Read (via the embedded +// driver) and Apply (returning an empty ApplyResult so the apply path is a +// no-op). Returns the cleanup function. +func installFakeRefreshProvider(t *testing.T, liveOutputs map[string]map[string]any) func() { + t.Helper() + orig := resolveIaCProvider + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { + return &refreshOutputsCmdFakeProvider{readOutputs: liveOutputs}, nil, nil + } + return func() { resolveIaCProvider = orig } +} + +// TestApply_PreStepRefresh_OptInViaEnvVar verifies that setting +// WFCTL_REFRESH_OUTPUTS=1 causes runInfraApply to read live Outputs for +// every state entry and persist new fields BEFORE the plan+apply phase. +// The end-to-end signal is the "id" field present in state on disk after +// apply returns. +func TestApply_PreStepRefresh_OptInViaEnvVar(t *testing.T) { + t.Setenv("WFCTL_REFRESH_OUTPUTS", "1") + dir := t.TempDir() + cfgPath := writeApplyPreRefreshConfig(t, dir) + seedStaleVPCState(t, cfgPath) + + cleanup := installFakeRefreshProvider(t, map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, + }) + defer cleanup() + + if _, err := captureStdout(t, func() error { + return runInfraApply([]string{"--auto-approve", "-c", cfgPath}) + }); err != nil { + t.Fatalf("runInfraApply: %v", err) + } + + got := loadVPCStateOutputs(t, cfgPath) + if id, _ := got["id"].(string); id != "uuid-1" { + t.Errorf("apply pre-refresh should populate 'id'; got %v", got) + } +} + +// TestApply_PreStepRefresh_DisabledByDefault verifies the opt-in semantics: +// without WFCTL_REFRESH_OUTPUTS set, the pre-step is skipped and stale +// state remains stale. +func TestApply_PreStepRefresh_DisabledByDefault(t *testing.T) { + // Clear any inherited value to make the test resilient under -count=N. + t.Setenv("WFCTL_REFRESH_OUTPUTS", "") + os.Unsetenv("WFCTL_REFRESH_OUTPUTS") + dir := t.TempDir() + cfgPath := writeApplyPreRefreshConfig(t, dir) + seedStaleVPCState(t, cfgPath) + + cleanup := installFakeRefreshProvider(t, map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, + }) + defer cleanup() + + if _, err := captureStdout(t, func() error { + return runInfraApply([]string{"--auto-approve", "-c", cfgPath}) + }); err != nil { + t.Fatalf("runInfraApply: %v", err) + } + + got := loadVPCStateOutputs(t, cfgPath) + if _, ok := got["id"]; ok { + t.Errorf("default-off pre-refresh should not populate 'id'; got %v", got) + } +} + +// TestApply_PreStepRefresh_EnvVarFalseyValueDisables pins the +// strconv.ParseBool semantic: "0" / "false" / "off" don't enable the +// pre-step. Operators routinely set env vars to "0" to disable a +// feature; without ParseBool that would be a foot-gun (any non-empty +// value enables, including "0"). One representative falsey value is +// enough — strconv.ParseBool's accept set is well-tested upstream. +func TestApply_PreStepRefresh_EnvVarFalseyValueDisables(t *testing.T) { + t.Setenv("WFCTL_REFRESH_OUTPUTS", "0") + dir := t.TempDir() + cfgPath := writeApplyPreRefreshConfig(t, dir) + seedStaleVPCState(t, cfgPath) + + cleanup := installFakeRefreshProvider(t, map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, + }) + defer cleanup() + + if _, err := captureStdout(t, func() error { + return runInfraApply([]string{"--auto-approve", "-c", cfgPath}) + }); err != nil { + t.Fatalf("runInfraApply: %v", err) + } + + got := loadVPCStateOutputs(t, cfgPath) + if _, ok := got["id"]; ok { + t.Errorf("WFCTL_REFRESH_OUTPUTS=0 must disable pre-refresh; got %v", got) + } +} + +// TestApply_PreStepRefresh_SkipFlagOverridesEnvVar verifies that +// --skip-refresh trumps WFCTL_REFRESH_OUTPUTS. Operators need a way to +// disable the pre-step in the same invocation that has the env var set +// (for example, when a CI environment forces it on globally). +func TestApply_PreStepRefresh_SkipFlagOverridesEnvVar(t *testing.T) { + t.Setenv("WFCTL_REFRESH_OUTPUTS", "1") + dir := t.TempDir() + cfgPath := writeApplyPreRefreshConfig(t, dir) + seedStaleVPCState(t, cfgPath) + + cleanup := installFakeRefreshProvider(t, map[string]map[string]any{ + "uuid-1": {"ip_range": "10.0.0.0/16", "id": "uuid-1"}, + }) + defer cleanup() + + if _, err := captureStdout(t, func() error { + return runInfraApply([]string{"--auto-approve", "--skip-refresh", "-c", cfgPath}) + }); err != nil { + t.Fatalf("runInfraApply: %v", err) + } + + got := loadVPCStateOutputs(t, cfgPath) + if _, ok := got["id"]; ok { + t.Errorf("--skip-refresh should suppress refresh even with env var set; got %v", got) + } +} From 8bbe4a4eeaa55dcc8c0be93b6cdf136c4d3e1b5e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 20:31:15 -0400 Subject: [PATCH 18/38] test(iac): concurrency stress test for refreshoutputs.Refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.5 — pure-package stress test in iac/refreshoutputs/. Drives Refresh with 100 fake resources at Concurrency=8 and asserts: 1. No deadlock (10s watchdog around the call). 2. Read called exactly once per ProviderID (atomic per-ID counter). 3. Every refreshed state carries the live Outputs map — no write-into-wrong-slot bug under concurrency. 4. Concurrent in-flight peak between 2 and the requested cap, proving both that parallelism happened AND that the semaphore enforced its limit. The countingDriver introduces a 5ms sleep per Read so the bounded pool actually queues at the cap (5ms × 100 / 8 ≈ 63ms total at peak; well under the 10s watchdog). Test runs ~1.5s wall. Co-Authored-By: Claude Opus 4.7 --- .../refresh_concurrency_test.go | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 iac/refreshoutputs/refresh_concurrency_test.go diff --git a/iac/refreshoutputs/refresh_concurrency_test.go b/iac/refreshoutputs/refresh_concurrency_test.go new file mode 100644 index 000000000..da747cb65 --- /dev/null +++ b/iac/refreshoutputs/refresh_concurrency_test.go @@ -0,0 +1,209 @@ +package refreshoutputs + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// countingProvider is a stress-test IaCProvider that hands out a single +// shared countingDriver. Every Read call increments the per-ProviderID +// counter so the test can assert "exactly once per resource" after +// Refresh returns. +type countingProvider struct { + driver *countingDriver +} + +func (p *countingProvider) Name() string { panic("not used") } +func (p *countingProvider) Version() string { panic("not used") } +func (p *countingProvider) Initialize(context.Context, map[string]any) error { + panic("not used") +} +func (p *countingProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { + panic("not used") +} +func (p *countingProvider) Plan(context.Context, []interfaces.ResourceSpec, []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + panic("not used") +} +func (p *countingProvider) Apply(context.Context, *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + panic("not used") +} +func (p *countingProvider) Destroy(context.Context, []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + panic("not used") +} +func (p *countingProvider) Status(context.Context, []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + panic("not used") +} +func (p *countingProvider) DetectDrift(context.Context, []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + panic("not used") +} +func (p *countingProvider) Import(context.Context, string, string) (*interfaces.ResourceState, error) { + panic("not used") +} +func (p *countingProvider) ResolveSizing(string, interfaces.Size, *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + panic("not used") +} +func (p *countingProvider) SupportedCanonicalKeys() []string { panic("not used") } +func (p *countingProvider) BootstrapStateBackend(context.Context, map[string]any) (*interfaces.BootstrapResult, error) { + panic("not used") +} +func (p *countingProvider) Close() error { return nil } + +func (p *countingProvider) ResourceDriver(string) (interfaces.ResourceDriver, error) { + return p.driver, nil +} + +// countingDriver atomically tracks how many times Read was called for +// each ProviderID and how many goroutines are inside Read at once. +// concurrentPeak gives the test a way to assert that the bounded +// semaphore actually enforced its cap. +type countingDriver struct { + mu sync.Mutex + callsByID map[string]int + inFlight atomic.Int32 + concurrentPeak atomic.Int32 +} + +func (d *countingDriver) Create(context.Context, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *countingDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + now := d.inFlight.Add(1) + defer d.inFlight.Add(-1) + for { + peak := d.concurrentPeak.Load() + if now <= peak || d.concurrentPeak.CompareAndSwap(peak, now) { + break + } + } + + // Hold each call long enough that, with N=100 and Concurrency=8, the + // bounded pool will queue up: every dispatched call MUST overlap with + // at least one other or the test's concurrency-cap check is vacuous. + // 5ms × 100 / 8 ≈ 63ms total wall time at the cap, well under the + // watchdog's 10s budget. + time.Sleep(5 * time.Millisecond) + + d.mu.Lock() + d.callsByID[ref.ProviderID]++ + d.mu.Unlock() + return &interfaces.ResourceOutput{ + Name: ref.Name, + Type: ref.Type, + ProviderID: ref.ProviderID, + // Add a "id" field so Refresh sees a diff and copies the new + // Outputs into out[i].Outputs — gives the test a positive + // per-resource signal that every refresh propagated. + Outputs: map[string]any{"id": ref.ProviderID, "fresh": true}, + }, nil +} +func (d *countingDriver) Update(context.Context, interfaces.ResourceRef, interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *countingDriver) Delete(context.Context, interfaces.ResourceRef) error { + panic("not used") +} +func (d *countingDriver) Diff(context.Context, interfaces.ResourceSpec, *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + panic("not used") +} +func (d *countingDriver) HealthCheck(context.Context, interfaces.ResourceRef) (*interfaces.HealthResult, error) { + panic("not used") +} +func (d *countingDriver) Scale(context.Context, interfaces.ResourceRef, int) (*interfaces.ResourceOutput, error) { + panic("not used") +} +func (d *countingDriver) SensitiveKeys() []string { return nil } + +// TestRefresh_ConcurrencyStress_NoDeadlock_AllRefreshed_OnceEach exercises +// Refresh against 100 resources with Concurrency=8 and asserts: +// +// 1. No deadlock — Refresh returns within a generous watchdog budget. +// Caught by `done` channel + 10s timeout `select`. +// 2. Read was called exactly once per resource — no double-dispatch +// hidden bugs in the semaphore acquire/release pairing. +// 3. The resulting state slice carries the refreshed Outputs for every +// entry — no goroutine wrote into the wrong out[i] under concurrency. +// 4. Concurrent peak in flight is between 2 and the requested +// concurrency cap. ≥2 confirms the bounded pool actually parallelised +// work; ≤cap confirms the semaphore enforced its limit. +func TestRefresh_ConcurrencyStress_NoDeadlock_AllRefreshed_OnceEach(t *testing.T) { + const ( + nResources = 100 + concurrency = 8 + watchdog = 10 * time.Second + ) + + states := make([]interfaces.ResourceState, nResources) + for i := range states { + id := fmt.Sprintf("uuid-%03d", i) + states[i] = interfaces.ResourceState{ + Name: fmt.Sprintf("vpc-%03d", i), + Type: "infra.vpc", + ProviderID: id, + Outputs: map[string]any{"ip_range": "10.0.0.0/16"}, + } + } + + driver := &countingDriver{callsByID: make(map[string]int, nResources)} + provider := &countingProvider{driver: driver} + + done := make(chan struct{}) + var refreshed []interfaces.ResourceState + var refreshErr error + go func() { + defer close(done) + refreshed, refreshErr = Refresh(context.Background(), provider, states, Options{Concurrency: concurrency}) + }() + select { + case <-done: + // Refresh returned — proceed with assertions. + case <-time.After(watchdog): + t.Fatalf("Refresh did not return within %s — possible deadlock", watchdog) + } + + if refreshErr != nil { + t.Fatalf("Refresh: %v", refreshErr) + } + if len(refreshed) != nResources { + t.Fatalf("expected %d refreshed states, got %d", nResources, len(refreshed)) + } + + // Each ProviderID should have been Read exactly once. + driver.mu.Lock() + defer driver.mu.Unlock() + if len(driver.callsByID) != nResources { + t.Errorf("expected reads for %d distinct ProviderIDs, got %d", nResources, len(driver.callsByID)) + } + for id, n := range driver.callsByID { + if n != 1 { + t.Errorf("ProviderID %q: Read called %d times, want exactly 1", id, n) + } + } + + // Every state in the result must carry the refreshed Outputs map + // (driver returns "id" + "fresh": true on every Read). + for i, s := range refreshed { + if got, _ := s.Outputs["id"].(string); got != fmt.Sprintf("uuid-%03d", i) { + t.Errorf("refreshed[%d]: Outputs[\"id\"]=%q, want uuid-%03d", i, got, i) + } + if got, _ := s.Outputs["fresh"].(bool); !got { + t.Errorf("refreshed[%d]: Outputs[\"fresh\"]=%v, want true", i, got) + } + } + + // Concurrency-cap invariants: peak inflight must have been >1 to + // prove parallelism happened, and ≤concurrency to prove the + // semaphore enforced its limit. + peak := int(driver.concurrentPeak.Load()) + if peak < 2 { + t.Errorf("concurrent peak in flight = %d; expected >=2 (parallelism not exercised)", peak) + } + if peak > concurrency { + t.Errorf("concurrent peak in flight = %d; expected <=%d (semaphore cap exceeded)", peak, concurrency) + } +} From e9a200ff6a76874ba87c0759a5477a5e3f7070f5 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 20:33:36 -0400 Subject: [PATCH 19/38] docs(wfctl): document infra refresh-outputs subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T2.6 — adds the infra refresh-outputs section to docs/WFCTL.md: - New row in the Command Tree mermaid graph. - New row in the infra Action table. - Dedicated #### subsection with usage, flag table, behavior summary, literal-error contract (load-bearing per T2.7), apply-time pre-step semantics (WFCTL_REFRESH_OUTPUTS, --skip-refresh), and three representative examples. See also: docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md records the T2.3 plan-deviation (ParseBool vs plan-literal presence check) that the docs in this commit accurately reflect. Verification — plan §T2.6 line 1090 invocation `mdformat --check docs/WFCTL.md && find docs -name "*.md" -exec markdown-link-check {} +` ran with locally-installed mdformat 1.0.0 (pip) and markdown-link-check 3.14.2 (npm): $ mdformat --check docs/WFCTL.md Error: File "docs/WFCTL.md" is not formatted. exit=1 This failure is PRE-EXISTING. Verified by checking out the file at the W-2 T2.2 tip (181e579) before any T2.6 edits and rerunning mdformat against it: identical error. docs/WFCTL.md has never been mdformat-formatted in this repo. Reformatting the entire file is out of scope for T2.6 (would introduce a multi-thousand-line unrelated diff). T2.6's own additions follow the existing in-file conventions exactly. $ markdown-link-check docs/WFCTL.md FILE: docs/WFCTL.md [✓] https://github.com/GoCodeAlone/workflow [✓] #build-ui [✓] mcp.md 3 links checked. exit=0 docs/WFCTL.md has zero broken links — including the new refresh-outputs section. The directory-wide scan reports 7 broken links in unrelated files (self-improvement-tutorial.md, getting-started.md, etc.); all are pre-existing and out of scope. T2.7 runtime-launch-validation transcript (folded into this commit body per the "Files: none new" plan note for T2.7): $ GOWORK=off go build -o /tmp/wfctl ./cmd/wfctl exit=0 $ /tmp/wfctl infra refresh-outputs --help Usage of infra refresh-outputs: -c string Config file (short for --config) -concurrency int Maximum concurrent Read calls (default 8) -config string Config file -e string Environment name (short for --env) -env string Environment name (resolves per-module overrides) exit=0 $ cat /tmp/t27-fake.yaml modules: - name: state-store type: iac.state config: backend: filesystem directory: /tmp/t27-fake-state $ /tmp/wfctl infra refresh-outputs -c /tmp/t27-fake.yaml --env staging error: refresh-outputs: provider not configured for env "staging" exit=1 No panic, no stack trace. Stderr line is the verbatim literal pinned by T2.7 (plan line 1098), produced by T2.2's fmt.Errorf("refresh-outputs: provider not configured for env %q", env) at cmd/wfctl/infra_refresh_outputs.go:49. PR W-2 mandate (plan line 1101): $ GOWORK=off go test ./iac/refreshoutputs/... ./cmd/wfctl/... -count=1 -race ok github.com/GoCodeAlone/workflow/iac/refreshoutputs 1.405s ok github.com/GoCodeAlone/workflow/cmd/wfctl 10.485s Manual smoke against staging-PG: not run — no staging-PG available in this worktree environment. Plan line 1102 marks this "if available", so deferring to the operator landing the PR. Co-Authored-By: Claude Opus 4.7 --- docs/WFCTL.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/docs/WFCTL.md b/docs/WFCTL.md index bc5f908d9..93063e910 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -96,6 +96,7 @@ graph TD infra --> infra-bootstrap["bootstrap"] infra --> infra-state["state"] infra --> infra-outputs["outputs"] + infra --> infra-refresh-outputs["refresh-outputs"] infra-state --> infra-state-list["list"] infra-state --> infra-state-export["export"] @@ -1171,6 +1172,7 @@ wfctl infra [options] [config.yaml] | `bootstrap` | Generate secrets and initialise state backend before first apply | | `state` | Manage state storage (list/export/import) | | `outputs` | Print resource outputs from state (yaml/json/env formats) | +| `refresh-outputs` | Read live outputs from each provider and reconcile state (no cloud writes) | | Flag | Default | Description | |------|---------|-------------| @@ -1216,6 +1218,63 @@ wfctl infra bootstrap -c infra.yaml --env staging --force-rotate NATS_AUTH_TOKEN wfctl infra bootstrap -c infra.yaml --force-rotate FOO --force-rotate BAR ``` +#### `infra refresh-outputs` + +Read live outputs from each `iac.provider` for resources already in state and persist any field-level changes back to the state backend. The contract is strictly read-only at the cloud level — `refresh-outputs` never invokes Update or Replace. + +``` +wfctl infra refresh-outputs [-c CONFIG] [--env ENV] [--concurrency N] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `-c`, `--config` | _(auto-detected)_ | Config file (searches `infra.yaml`, `config/infra.yaml`) | +| `-e`, `--env` | `` | Environment name (resolves per-module overrides; iac.provider modules disabled for the env are skipped) | +| `--concurrency` | `8` | Maximum concurrent Read calls. Values < 1 fall back to the default. | + +**Behavior:** + +- Discovers `iac.provider` modules with per-env resolution. +- Loads current state from the configured `iac.state` backend. +- Groups state entries by their owning provider module (`provider_ref` first, falling back to provider type when exactly one module of that type is declared). +- Calls each provider's `ResourceDriver.Read` once per resource via the bounded-concurrency `iac/refreshoutputs.Refresh` helper. +- Persists any state entry whose `outputs` map changed — entries whose live outputs equal the persisted outputs are left alone. + +**Errors:** + +When the resolved config has no usable `iac.provider` module for the requested env, `wfctl` exits 1 with the literal stderr line: + +``` +error: refresh-outputs: provider not configured for env "" +``` + +This wording is load-bearing — CI gates and runtime-launch validation pin the exact form. On any provider Read or driver-resolution failure, the command returns the wrapped error from the `iac/refreshoutputs` helper without persisting partial progress. + +**Apply-time pre-step (opt-in):** + +`wfctl infra apply` can run the same refresh as a pre-plan step, ensuring the planner doesn't make decisions against stale outputs. + +| Variable / Flag | Effect | +|------|---------| +| `WFCTL_REFRESH_OUTPUTS=1` (or any `strconv.ParseBool` truthy value) | Enable the apply pre-step. | +| `WFCTL_REFRESH_OUTPUTS=0` (or any falsey value, empty, or unrecognised) | Disable the apply pre-step (default). | +| `wfctl infra apply --skip-refresh` | Suppress the apply pre-step regardless of the env var (CI escape hatch). | + +The pre-step only fires for `infra.*` configs; legacy `platform.*` configs are silently skipped. + +**Examples:** + +```bash +# One-off explicit refresh against the staging env. +wfctl infra refresh-outputs -c infra.yaml --env staging + +# Apply with pre-plan refresh enabled. +WFCTL_REFRESH_OUTPUTS=1 wfctl infra apply --auto-approve -c infra.yaml --env staging + +# Apply with pre-step suppressed even though CI exports the env var. +WFCTL_REFRESH_OUTPUTS=1 wfctl infra apply --auto-approve --skip-refresh -c infra.yaml +``` + --- ### `docs generate` From f42f5360f3a18b278d5301beced53dd2e34accea Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 20:42:39 -0400 Subject: [PATCH 20/38] =?UTF-8?q?docs(adr):=20record=20WFCTL=5FREFRESH=5FO?= =?UTF-8?q?UTPUTS=20ParseBool=20semantics=20deviation=20from=20plan=20?= =?UTF-8?q?=C2=A7T2.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 006 — formalises the spec-vs-quality-review trade-off recorded during W-2 T2.3 review: - Plan §T2.3 line 1061 specified `os.Getenv("WFCTL_REFRESH_OUTPUTS") != ""`. - Code-reviewer flagged this as a foot-gun (=0 mis-enables). - Implementation at cmd/wfctl/infra_apply_refresh_pre.go (bfd1bbe) uses strconv.ParseBool so falsey values explicitly disable. - Spec-reviewer accepted post-hoc and requested this ADR per superpowers:recording-decisions. - Team-lead approved option-1 (approve-as-is + follow-up ADR) over a plan revert; provenance recorded in the ADR itself. Captures the rejected alternative, the rationale, references back to the plan spec, the implementation site, the pinning test, and the operator-facing docs. Co-Authored-By: Claude Opus 4.7 --- ...wfctl-refresh-outputs-env-var-parsebool.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md diff --git a/docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md b/docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md new file mode 100644 index 000000000..09e8859eb --- /dev/null +++ b/docs/adr/006-wfctl-refresh-outputs-env-var-parsebool.md @@ -0,0 +1,51 @@ +# ADR 006: WFCTL_REFRESH_OUTPUTS uses strconv.ParseBool, not bare presence + +## Status + +Accepted + +## Context + +W-2 Task T2.3 (`docs/plans/2026-05-03-iac-conformance-and-replace.md`, line 1061) specified the apply-time pre-step opt-in gate as a bare presence check: + +```go +if os.Getenv("WFCTL_REFRESH_OUTPUTS") != "" { + // run pre-step +} +``` + +Under that contract, any non-empty value enables the pre-step — including values an operator would universally read as disabling: `0`, `false`, `no`, `off`. T2.3's first quality review (code-reviewer) flagged this as a foot-gun: operators reach for the `=0` / `=false` convention to disable a feature, and the bare-presence check turns that convention into the opposite of its intent. The original commit body also leaned into the wrong mental model by listing `"yes"` as an enabling example, reinforcing the misread that the value was being parsed. + +`--skip-refresh` exists as a robust off-switch override, so an operator who falls into the foot-gun has an escape hatch — but discovering it requires reading the docs after the surprise hit, by which point a planner has already burned a `Read` round-trip on every state entry. + +## Decision + +Implement the gate using `strconv.ParseBool` instead of bare presence. The accept set is well-known to Go developers and matches every common operator convention: + +- Truthy enables: `1`, `t`, `T`, `TRUE`, `true`, `True` +- Falsey explicitly disables: `0`, `f`, `F`, `FALSE`, `false`, `False` +- Empty / unset / unrecognised: disabled (default; never an exception) + +`--skip-refresh` continues to override the env var unconditionally, preserving the CI escape-hatch contract for environments where the env var is forced on globally. + +## Consequences + +**Positive:** + +- Operator surprise eliminated: `WFCTL_REFRESH_OUTPUTS=0` disables, as expected. +- Self-documenting contract: `ParseBool` is a familiar Go idiom; the godoc on `applyPreStepRefreshEnabled` enumerates the accept set. +- Future-proofed against the same bug class for other refresh-outputs related env vars added in W-3+. + +**Negative:** + +- Plan-deviation: the implementation diverges from `docs/plans/2026-05-03-iac-conformance-and-replace.md` line 1061's literal contract. Recorded in this ADR so future contributors see the rejected alternative and the reasoning, rather than discovering it via `git blame` archaeology. A future plan revision should reflect the actual implementation; out of scope for this ADR. +- Operators who set the env var to an unrecognised string (e.g. `WFCTL_REFRESH_OUTPUTS=enabled`) silently get the disabled default. The accept set is intentionally narrow; the alternative — accepting any non-empty as truthy — is exactly the foot-gun this ADR rejects. Mitigated by the doc table in `docs/WFCTL.md` enumerating the truthy/falsey values explicitly. + +**Provenance:** decided by Claude (autonomous-pipeline team-lead) after code-review/spec-review consensus on 2026-05-04. code-reviewer surfaced the foot-gun during T2.3 quality review; implementer prepared the ParseBool change; spec-reviewer requested this ADR post-hoc; team-lead approved option-1 (approve-as-is + follow-up ADR) over plan revert. + +## References + +- Plan task spec: `docs/plans/2026-05-03-iac-conformance-and-replace.md` §T2.3 (line 1061) +- Implementation: `cmd/wfctl/infra_apply_refresh_pre.go::applyPreStepRefreshEnabled` (commit `bfd1bbe`) +- Test pinning the falsey-disables semantic: `cmd/wfctl/infra_apply_refresh_pre_test.go::TestApply_PreStepRefresh_EnvVarFalseyValueDisables` +- Operator-facing docs: `docs/WFCTL.md` → `infra refresh-outputs` → "Apply-time pre-step (opt-in)" From 13a6fad91057bdf113ff6134f21261dbfef7f91b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 21:49:43 -0400 Subject: [PATCH 21/38] feat(iac): add ApplyResult.InitialInputSnapshot + InputDriftReport + ReplaceIDMap fields --- interfaces/iac_state.go | 22 ++++++++++++++ interfaces/iac_state_test.go | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/interfaces/iac_state.go b/interfaces/iac_state.go index 668b834b1..f08e0f94b 100644 --- a/interfaces/iac_state.go +++ b/interfaces/iac_state.go @@ -107,6 +107,28 @@ type ApplyResult struct { PlanID string `json:"plan_id"` Resources []ResourceOutput `json:"resources"` Errors []ActionError `json:"errors,omitempty"` + + // InitialInputSnapshot captures env-var fingerprints at the start of apply. + // Populated by wfctlhelpers.ApplyPlan (T3.1) at apply entry by snapshotting + // every name listed in plan.InputSnapshot through inputsnapshot.OSEnvProvider. + // Used by the deferred postcondition (T3.1.5) to compute the drift report + // against the apply-time snapshot. + InitialInputSnapshot map[string]string `json:"initial_input_snapshot,omitempty"` + + // InputDriftReport names env-vars whose fingerprint changed between plan + // and apply. Populated by the deferred postcondition in + // wfctlhelpers.ApplyPlan (T3.1.5) regardless of whether the apply + // itself succeeded or errored. Empty (or nil) means no drift detected. + // Entries are sorted by Name for deterministic comparison and stable + // diagnostic output. + InputDriftReport []DriftEntry `json:"input_drift_report,omitempty"` + + // ReplaceIDMap propagates new ProviderIDs from Replace actions to + // dependent resources whose Apply runs later in the same plan. + // Populated by doReplace (T3.4); consumed by JIT substitution in W-5 + // (T5.2/T5.3). Keyed by the dependent resource Name; the value is the + // new ProviderID returned from the post-Delete Create. + ReplaceIDMap map[string]string `json:"replace_id_map,omitempty"` } // DestroyResult summarises the outcome of a destroy operation. diff --git a/interfaces/iac_state_test.go b/interfaces/iac_state_test.go index 494bde584..c53b2910b 100644 --- a/interfaces/iac_state_test.go +++ b/interfaces/iac_state_test.go @@ -53,3 +53,60 @@ func TestPlanAction_ResolvedConfigHashField(t *testing.T) { t.Errorf("ResolvedConfigHash: got %q want %q", got.ResolvedConfigHash, realisticHash) } } + +// TestApplyResult_InputDriftReport_RoundTrip verifies the InputDriftReport +// field declared in T3.0.4. Field is populated by the deferred postcondition +// in wfctlhelpers.ApplyPlan (T3.1.5). +func TestApplyResult_InputDriftReport_RoundTrip(t *testing.T) { + r := ApplyResult{InputDriftReport: []DriftEntry{ + {Name: "STAGING_PG_PASSWORD", PlanFingerprint: "abc", ApplyFingerprint: "def"}, + }} + data, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var got ApplyResult + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if len(got.InputDriftReport) != 1 || got.InputDriftReport[0].Name != "STAGING_PG_PASSWORD" { + t.Errorf("InputDriftReport roundtrip failed: %+v", got) + } +} + +// TestApplyResult_InitialInputSnapshot_RoundTrip verifies the +// InitialInputSnapshot field declared in T3.0.4. Field is populated at apply +// entry by wfctlhelpers.ApplyPlan (T3.1) and consumed by the deferred +// postcondition (T3.1.5) when computing drift. +func TestApplyResult_InitialInputSnapshot_RoundTrip(t *testing.T) { + r := ApplyResult{InitialInputSnapshot: map[string]string{"FOO": "fp1234"}} + data, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var got ApplyResult + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.InitialInputSnapshot["FOO"] != "fp1234" { + t.Errorf("InitialInputSnapshot roundtrip failed: %+v", got) + } +} + +// TestApplyResult_ReplaceIDMap_RoundTrip verifies the ReplaceIDMap field +// declared in T3.0.4. Field is populated by doReplace (T3.4) and consumed +// by JIT substitution in W-5 (T5.2/T5.3). +func TestApplyResult_ReplaceIDMap_RoundTrip(t *testing.T) { + r := ApplyResult{ReplaceIDMap: map[string]string{"vpc": "new-uuid"}} + data, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var got ApplyResult + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.ReplaceIDMap["vpc"] != "new-uuid" { + t.Errorf("ReplaceIDMap roundtrip failed: %+v", got) + } +} From 8416498a8545d1da6b745dbdd7a27347960d4103 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 21:52:58 -0400 Subject: [PATCH 22/38] feat(iac): add wfctlhelpers.ApplyPlan skeleton (4-action dispatch) --- iac/wfctlhelpers/apply.go | 151 ++++++++++++++++++++++++++++ iac/wfctlhelpers/apply_test.go | 177 +++++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 iac/wfctlhelpers/apply.go create mode 100644 iac/wfctlhelpers/apply_test.go diff --git a/iac/wfctlhelpers/apply.go b/iac/wfctlhelpers/apply.go new file mode 100644 index 000000000..562160056 --- /dev/null +++ b/iac/wfctlhelpers/apply.go @@ -0,0 +1,151 @@ +// Package wfctlhelpers hosts the wfctl-side dispatch helper for v2 IaC +// plugins. wfctl calls [ApplyPlan] when a plugin manifest declares +// iacProvider.computePlanVersion: v2 (see plugin/sdk.IaCProvider). The +// helper iterates plan.Actions, fetches the matching ResourceDriver from +// the provider, and dispatches each action to a per-action sub-function +// (doCreate, doUpdate, doReplace, doDelete). +// +// Lifecycle inside W-3a: +// +// - T3.1 (this file's ApplyPlan + dispatch + skeleton sub-functions) +// - T3.1.5 — wraps ApplyPlan with the input-drift postcondition +// - T3.2 — fills doCreate with UpsertSupporter recovery +// - T3.3 — fills doUpdate + doDelete (the latent doDelete bug fix) +// - T3.4 — fills doReplace and populates ApplyResult.ReplaceIDMap +// +// Until W-3b lands the cmd/wfctl dispatch wiring, [ApplyPlan] has no +// in-tree caller — the helper ships in W-3a as foundation only and is +// exercised solely by this package's tests. +package wfctlhelpers + +import ( + "context" + "fmt" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// ApplyPlan dispatches each plan action to the matching ResourceDriver on +// the provider. Per-action errors are recorded on result.Errors and do NOT +// abort the loop — apply best-effort across actions, surface every failure +// for the operator to triage. +// +// The function is concurrency-safe with respect to its inputs: result is +// owned by ApplyPlan for the duration of the call and is not shared with +// the provider or driver implementations. +// +// T3.1 ships the dispatch skeleton. T3.1.5 wraps the body with the input- +// drift postcondition; T3.2/T3.3/T3.4 fill the per-action sub-functions +// with their full bodies. Test smoke-coverage verifies dispatch wiring; +// per-action behavior is exercised by the per-task test files. +func ApplyPlan(ctx context.Context, p interfaces.IaCProvider, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + result := &interfaces.ApplyResult{PlanID: plan.ID} + for _, action := range plan.Actions { + d, err := p.ResourceDriver(action.Resource.Type) + if err != nil { + result.Errors = append(result.Errors, interfaces.ActionError{ + Resource: action.Resource.Name, + Action: action.Action, + Error: fmt.Errorf("resolve driver: %w", err).Error(), + }) + continue + } + if err := dispatchAction(ctx, d, action, result); err != nil { + result.Errors = append(result.Errors, interfaces.ActionError{ + Resource: action.Resource.Name, + Action: action.Action, + Error: err.Error(), + }) + } + } + return result, nil +} + +// dispatchAction routes a single PlanAction to the per-kind sub-function. +// An unknown action kind returns an error which ApplyPlan records on +// result.Errors so an operator running a malformed plan sees a per-action +// diagnostic rather than a silent skip. +func dispatchAction(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult) error { + switch action.Action { + case "create": + return doCreate(ctx, d, action, result) + case "update": + return doUpdate(ctx, d, action, result) + case "replace": + return doReplace(ctx, d, action, result) + case "delete": + return doDelete(ctx, d, action) + default: + return fmt.Errorf("unknown action %q", action.Action) + } +} + +// doCreate is the T3.1 skeleton — invokes Create and appends the output +// to result.Resources on success. T3.2 will replace this with the full +// UpsertSupporter recovery path on ErrResourceAlreadyExists. +func doCreate(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult) error { + out, err := d.Create(ctx, action.Resource) + if err != nil { + return err + } + if out != nil { + result.Resources = append(result.Resources, *out) + } + return nil +} + +// doUpdate is the T3.1 skeleton — invokes Update with a ResourceRef +// derived from action.Current's ProviderID (when present) and appends the +// output to result.Resources on success. T3.3 will fill in the typed +// pre-condition checks. +func doUpdate(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult) error { + ref := refFromAction(action) + out, err := d.Update(ctx, ref, action.Resource) + if err != nil { + return err + } + if out != nil { + result.Resources = append(result.Resources, *out) + } + return nil +} + +// doReplace is the T3.1 skeleton — Delete + Create. T3.4 fills in the +// ReplaceIDMap propagation so dependent resources can pick up the new +// ProviderID via JIT substitution in W-5. +func doReplace(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult) error { + if err := d.Delete(ctx, refFromAction(action)); err != nil { + return fmt.Errorf("replace: delete: %w", err) + } + out, err := d.Create(ctx, action.Resource) + if err != nil { + return fmt.Errorf("replace: create: %w", err) + } + if out != nil { + result.Resources = append(result.Resources, *out) + } + return nil +} + +// doDelete is the T3.1 skeleton — invokes Delete. T3.3 fills in the typed +// pre-condition checks; this skeleton already closes the latent gap noted +// in the design (DOProvider.Apply has no "case delete" today). +func doDelete(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction) error { + return d.Delete(ctx, refFromAction(action)) +} + +// refFromAction builds a ResourceRef from the action's resource identity, +// threading the ProviderID from action.Current when the plan was built +// from existing state. For net-new actions (action.Current == nil) the +// returned ref has an empty ProviderID, matching the contract that +// drivers locate by Name when ProviderID is absent. +func refFromAction(action interfaces.PlanAction) interfaces.ResourceRef { + ref := interfaces.ResourceRef{ + Name: action.Resource.Name, + Type: action.Resource.Type, + } + if action.Current != nil { + ref.ProviderID = action.Current.ProviderID + } + return ref +} diff --git a/iac/wfctlhelpers/apply_test.go b/iac/wfctlhelpers/apply_test.go new file mode 100644 index 000000000..ef96b7b76 --- /dev/null +++ b/iac/wfctlhelpers/apply_test.go @@ -0,0 +1,177 @@ +package wfctlhelpers + +import ( + "context" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// fakeDriver records which CRUD methods ApplyPlan invokes. All methods +// succeed; tests assert on the recorded flags. Each test gets a fresh +// instance via newFakeDriver(). +type fakeDriver struct { + calledCreate bool + calledUpdate bool + calledDelete bool +} + +func (d *fakeDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.calledCreate = true + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "fake-id-" + spec.Name}, nil +} + +func (d *fakeDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + return &interfaces.ResourceOutput{Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID}, nil +} + +func (d *fakeDriver) Update(_ context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.calledUpdate = true + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: ref.ProviderID}, nil +} + +func (d *fakeDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { + d.calledDelete = true + return nil +} + +func (d *fakeDriver) Diff(_ context.Context, _ interfaces.ResourceSpec, _ *interfaces.ResourceOutput) (*interfaces.DiffResult, error) { + return &interfaces.DiffResult{}, nil +} + +func (d *fakeDriver) HealthCheck(_ context.Context, _ interfaces.ResourceRef) (*interfaces.HealthResult, error) { + return &interfaces.HealthResult{Healthy: true}, nil +} + +func (d *fakeDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) { + return nil, nil +} + +func (d *fakeDriver) SensitiveKeys() []string { return nil } + +// fakeProvider returns the same fakeDriver for every resource type so a +// single instance can record all dispatch invocations within one +// ApplyPlan call. +type fakeProvider struct { + driver *fakeDriver +} + +func newFakeProvider() *fakeProvider { return &fakeProvider{driver: &fakeDriver{}} } + +// calledReplace is true iff doReplace ran — Replace decomposes into +// Delete-then-Create on the driver, so we infer it from a Delete preceded +// by a Create on the same recorder, but for the dispatch smoke test we +// instead expose it via a derived helper. +func (p *fakeProvider) calledReplace() bool { return p.driver.calledDelete && p.driver.calledCreate } + +func (p *fakeProvider) Name() string { return "fake" } +func (p *fakeProvider) Version() string { return "0.0.0" } +func (p *fakeProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } +func (p *fakeProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } +func (p *fakeProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + return &interfaces.IaCPlan{}, nil +} +func (p *fakeProvider) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + return &interfaces.ApplyResult{}, nil +} +func (p *fakeProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + return nil, nil +} +func (p *fakeProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + return nil, nil +} +func (p *fakeProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + return nil, nil +} +func (p *fakeProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) { + return nil, nil +} +func (p *fakeProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + return nil, nil +} +func (p *fakeProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return p.driver, nil +} +func (p *fakeProvider) SupportedCanonicalKeys() []string { return nil } +func (p *fakeProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { + return nil, nil +} +func (p *fakeProvider) Close() error { return nil } + +// spec is a minimal ResourceSpec helper for tests. +func spec(name, typ string) interfaces.ResourceSpec { + return interfaces.ResourceSpec{Name: name, Type: typ} +} + +// state is a minimal ResourceState helper for tests; sets a deterministic +// ProviderID so doUpdate / doDelete / doReplace have something to thread. +func state(name string) *interfaces.ResourceState { + return &interfaces.ResourceState{Name: name, ProviderID: "old-id-" + name} +} + +// TestApplyPlan_HandlesAllFourActions is the T3.1 dispatch smoke test: +// one PlanAction of each kind (create/update/replace/delete) must reach +// the ResourceDriver. Bodies of doCreate/doUpdate/doReplace/doDelete are +// minimal stubs in T3.1 — full upsert / replace-id-propagation / etc. +// land in T3.2 / T3.3 / T3.4. This test only asserts dispatch wiring. +func TestApplyPlan_HandlesAllFourActions(t *testing.T) { + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("a", "infra.vpc")}, + {Action: "update", Resource: spec("b", "infra.vpc"), Current: state("b")}, + {Action: "replace", Resource: spec("c", "infra.vpc"), Current: state("c")}, + {Action: "delete", Resource: spec("d", "infra.vpc"), Current: state("d")}, + }, + } + fp := newFakeProvider() + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.Errors) != 0 { + t.Errorf("expected no errors, got %v", result.Errors) + } + if !fp.driver.calledCreate || !fp.driver.calledUpdate || !fp.calledReplace() || !fp.driver.calledDelete { + t.Errorf("not all action types invoked: create=%v update=%v replace=%v delete=%v", + fp.driver.calledCreate, fp.driver.calledUpdate, fp.calledReplace(), fp.driver.calledDelete) + } +} + +// TestApplyPlan_UnknownActionRecordsError verifies the dispatch's +// default-case behavior: an action with an unrecognised kind should not +// crash but must surface a per-action error in result.Errors so an +// operator running a malformed plan sees the diagnostic instead of a +// silently dropped action. +func TestApplyPlan_UnknownActionRecordsError(t *testing.T) { + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "frobnicate", Resource: spec("x", "infra.vpc")}, + }, + } + fp := newFakeProvider() + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatalf("ApplyPlan should not return a top-level error for a per-action issue; got %v", err) + } + if len(result.Errors) != 1 { + t.Fatalf("expected 1 per-action error, got %d (%v)", len(result.Errors), result.Errors) + } + if result.Errors[0].Resource != "x" || result.Errors[0].Action != "frobnicate" { + t.Errorf("unexpected error shape: %+v", result.Errors[0]) + } +} + +// TestApplyPlan_PreservesPlanID checks the ApplyResult.PlanID echo. Future +// log-correlation paths rely on this so an operator can match an apply +// invocation back to the persisted plan. +func TestApplyPlan_PreservesPlanID(t *testing.T) { + plan := &interfaces.IaCPlan{ID: "plan-12345"} + fp := newFakeProvider() + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if result.PlanID != "plan-12345" { + t.Errorf("PlanID: got %q want %q", result.PlanID, "plan-12345") + } +} From d3073d79a2ff4b805d4c05b7bfb9fad60a7fd510 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 21:54:34 -0400 Subject: [PATCH 23/38] =?UTF-8?q?fix(iac):=20T3.0.4=20review=20=E2=80=94?= =?UTF-8?q?=20correct=20ReplaceIDMap=20key=20direction=20+=20lock=20omitem?= =?UTF-8?q?pty=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-reviewer findings on commit 13a6fad: - Important: ReplaceIDMap godoc said "Keyed by the dependent resource Name" but the populating site (T3.4 plan §1625) sets result.ReplaceIDMap[action.Resource.Name] where action.Resource is the REPLACED resource. The roundtrip fixture {"vpc":"new-uuid"} confirms this. Re-worded to "Keyed by the *replaced* resource's Name" with an explicit reference to action.Resource.Name + a sentence on how W-5 JIT substitution will use the map (lookup by replaced-resource name to obtain the new ProviderID for dependent configs). Locks the contract before the field has any consumers. - Minor: cross-referenced the InputDriftReport sort-stability guarantee to its enforcing test (TestComputeDrift_ResultIsSortedByName in iac/inputsnapshot/compute_drift_test.go) so the contract is no longer free-floating on the field godoc. - Minor: added TestApplyResult_OmitEmptyContract — table-driven across nil and empty-but-non-nil values for all three new fields, asserting the JSON keys are absent from the encoded form. Locks the omitempty tag behavior so a future refactor cannot silently regress to emitting "initial_input_snapshot": {} / "input_drift_report": [] / "replace_id_map": {}. --- interfaces/iac_state.go | 15 ++++++++++--- interfaces/iac_state_test.go | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/interfaces/iac_state.go b/interfaces/iac_state.go index f08e0f94b..c0dd3caf5 100644 --- a/interfaces/iac_state.go +++ b/interfaces/iac_state.go @@ -120,14 +120,23 @@ type ApplyResult struct { // wfctlhelpers.ApplyPlan (T3.1.5) regardless of whether the apply // itself succeeded or errored. Empty (or nil) means no drift detected. // Entries are sorted by Name for deterministic comparison and stable - // diagnostic output. + // diagnostic output; the sort guarantee is enforced by + // inputsnapshot.ComputeDrift (covered by + // TestComputeDrift_ResultIsSortedByName in + // iac/inputsnapshot/compute_drift_test.go). InputDriftReport []DriftEntry `json:"input_drift_report,omitempty"` // ReplaceIDMap propagates new ProviderIDs from Replace actions to // dependent resources whose Apply runs later in the same plan. // Populated by doReplace (T3.4); consumed by JIT substitution in W-5 - // (T5.2/T5.3). Keyed by the dependent resource Name; the value is the - // new ProviderID returned from the post-Delete Create. + // (T5.2/T5.3). + // + // Keyed by the *replaced* resource's Name (i.e., action.Resource.Name + // from the Replace PlanAction — the resource whose Delete-then-Create + // just produced a fresh ProviderID). The value is the new ProviderID + // returned from the post-Delete Create. Dependent resources reference + // the replaced resource by name in their config, so JIT substitution + // in W-5 translates "name → new ProviderID" via this map. ReplaceIDMap map[string]string `json:"replace_id_map,omitempty"` } diff --git a/interfaces/iac_state_test.go b/interfaces/iac_state_test.go index c53b2910b..90f4e0349 100644 --- a/interfaces/iac_state_test.go +++ b/interfaces/iac_state_test.go @@ -110,3 +110,46 @@ func TestApplyResult_ReplaceIDMap_RoundTrip(t *testing.T) { t.Errorf("ReplaceIDMap roundtrip failed: %+v", got) } } + +// TestApplyResult_OmitEmptyContract locks in the omitempty JSON tag +// behavior on the three T3.0.4 fields. Both nil and empty-but-non-nil +// values must drop from the encoded form so plan/result transcripts stay +// lean and downstream consumers can treat "absent key" and "empty value" +// identically — matching the behavior already documented for +// IaCPlan.InputSnapshot and PlanAction.ResolvedConfigHash. +func TestApplyResult_OmitEmptyContract(t *testing.T) { + cases := map[string]ApplyResult{ + "nil-fields": {}, + "empty-non-nil-fields": { + InitialInputSnapshot: map[string]string{}, + InputDriftReport: []DriftEntry{}, + ReplaceIDMap: map[string]string{}, + }, + } + for name, r := range cases { + t.Run(name, func(t *testing.T) { + data, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + s := string(data) + for _, key := range []string{"initial_input_snapshot", "input_drift_report", "replace_id_map"} { + if containsString(s, key) { + t.Errorf("expected %q to be omitted from %s; got %s", key, name, s) + } + } + }) + } +} + +// containsString is a tiny, dependency-free substring helper local to this +// test file so the omitempty test does not pull in strings just for one +// check (the file's other tests use only encoding/json + testing). +func containsString(s, substr string) bool { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} From 6dbe501ed79816335d24222c63e547d21e4137f0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:00:59 -0400 Subject: [PATCH 24/38] =?UTF-8?q?fix(iac):=20T3.1=20review=20=E2=80=94=20s?= =?UTF-8?q?trengthen=20Replace=20coverage=20+=20ctx-cancel=20+=20driver-re?= =?UTF-8?q?solve=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-reviewer findings on commit 8416498: - Important 1 (weak Replace assertion): converted fakeDriver from boolean call recorders to integer counters. The 4-action plan [create, update, replace, delete] now asserts Create==2, Update==1, Delete==2. If "case replace" were silently dropped from dispatchAction the counts would shift to 1/1/1 and the test would fail. Added TestApplyPlan_ReplaceDispatchesViaDeleteThenCreate that isolates Replace via a single-action plan: 1 Delete + 1 Create + 0 Update. Removes the calledReplace() proxy entirely. - Important 2 (resolve-driver-error path uncovered): added TestApplyPlan_ResolveDriverErrorRecordsActionError which exercises fakeProvider.driverErr, asserts the canonical "resolve driver:" prefix, and verifies the loop continues past action[0] to action[1] (best-effort contract). Folded the loop-continues-after-failure coverage into a separate TestApplyPlan_LoopContinuesAfterPerActionFailure using a selectiveFakeProvider that errors on one type only — proves one action's failure does not block another's success. - Minor 1 (wasted %w): switched fmt.Errorf(...).Error() to fmt.Sprintf("resolve driver: %v", err) since the destination is a string field and the wrapping chain dies at the field boundary. - Minor 3 (ctx.Done not checked): added ctx.Err() check at the loop iteration boundary; on cancel, returns the result accumulated so far + the ctx error as top-level. Added TestApplyPlan_CtxCancellationStopsLoop covering pre-call cancel: driver receives zero invocations, top-level error is context.Canceled. - Minor 5 (refFromAction defensive note): added a godoc paragraph documenting the same-name-same-type invariant for Replace plans. Documenting rather than enforcing — ComputePlan upstream is the contract owner. Minor 2 (uniform error prefixing across sub-functions) intentionally deferred to T3.2/T3.3/T3.4 per reviewer guidance — those tasks own the final sub-function bodies and can pick the convention once. --- iac/wfctlhelpers/apply.go | 22 +++- iac/wfctlhelpers/apply_test.go | 208 +++++++++++++++++++++++++++++---- 2 files changed, 205 insertions(+), 25 deletions(-) diff --git a/iac/wfctlhelpers/apply.go b/iac/wfctlhelpers/apply.go index 562160056..663e6eef5 100644 --- a/iac/wfctlhelpers/apply.go +++ b/iac/wfctlhelpers/apply.go @@ -28,7 +28,10 @@ import ( // ApplyPlan dispatches each plan action to the matching ResourceDriver on // the provider. Per-action errors are recorded on result.Errors and do NOT // abort the loop — apply best-effort across actions, surface every failure -// for the operator to triage. +// for the operator to triage. Context cancellation between actions IS +// respected: when ctx is canceled or its deadline expires, the loop stops +// at the next iteration boundary and returns ctx.Err() as the top-level +// error so a long apply terminates promptly on Ctrl-C / SIGTERM. // // The function is concurrency-safe with respect to its inputs: result is // owned by ApplyPlan for the duration of the call and is not shared with @@ -41,12 +44,19 @@ import ( func ApplyPlan(ctx context.Context, p interfaces.IaCProvider, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { result := &interfaces.ApplyResult{PlanID: plan.ID} for _, action := range plan.Actions { + // Honor cancellation at the loop boundary. Drivers should also + // check ctx internally for in-flight work, but the loop check + // guarantees apply stops between actions even if a driver + // happens to ignore ctx. + if err := ctx.Err(); err != nil { + return result, err + } d, err := p.ResourceDriver(action.Resource.Type) if err != nil { result.Errors = append(result.Errors, interfaces.ActionError{ Resource: action.Resource.Name, Action: action.Action, - Error: fmt.Errorf("resolve driver: %w", err).Error(), + Error: fmt.Sprintf("resolve driver: %v", err), }) continue } @@ -139,6 +149,14 @@ func doDelete(ctx context.Context, d interfaces.ResourceDriver, action interface // from existing state. For net-new actions (action.Current == nil) the // returned ref has an empty ProviderID, matching the contract that // drivers locate by Name when ProviderID is absent. +// +// Invariant: callers MUST ensure action.Current's Name/Type match +// action.Resource — Replace plans assume same-name same-type. If a future +// plan generator emits a Replace where Current.Name != Resource.Name +// (e.g., a rename across replace), the Delete would target the new name +// with the old ProviderID — likely a "not found" or wrong-resource bug. +// This function does not enforce the invariant; the contract is upstream +// in ComputePlan. func refFromAction(action interfaces.PlanAction) interfaces.ResourceRef { ref := interfaces.ResourceRef{ Name: action.Resource.Name, diff --git a/iac/wfctlhelpers/apply_test.go b/iac/wfctlhelpers/apply_test.go index ef96b7b76..5432740c7 100644 --- a/iac/wfctlhelpers/apply_test.go +++ b/iac/wfctlhelpers/apply_test.go @@ -2,22 +2,28 @@ package wfctlhelpers import ( "context" + "errors" + "strings" "testing" "github.com/GoCodeAlone/workflow/interfaces" ) -// fakeDriver records which CRUD methods ApplyPlan invokes. All methods -// succeed; tests assert on the recorded flags. Each test gets a fresh -// instance via newFakeDriver(). +// fakeDriver counts CRUD-method invocations so dispatch tests can prove +// each per-action sub-function ran the right number of times. Counters +// are integers (not booleans) because the Replace action decomposes into +// Delete-then-Create on the driver — a boolean recorder can't tell +// "Replace dispatched correctly" apart from "explicit Create + explicit +// Delete also ran in the same plan." Each test gets a fresh instance via +// newFakeProvider(). type fakeDriver struct { - calledCreate bool - calledUpdate bool - calledDelete bool + createCount int + updateCount int + deleteCount int } func (d *fakeDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - d.calledCreate = true + d.createCount++ return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "fake-id-" + spec.Name}, nil } @@ -26,12 +32,12 @@ func (d *fakeDriver) Read(_ context.Context, ref interfaces.ResourceRef) (*inter } func (d *fakeDriver) Update(_ context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { - d.calledUpdate = true + d.updateCount++ return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: ref.ProviderID}, nil } func (d *fakeDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { - d.calledDelete = true + d.deleteCount++ return nil } @@ -51,19 +57,16 @@ func (d *fakeDriver) SensitiveKeys() []string { return nil } // fakeProvider returns the same fakeDriver for every resource type so a // single instance can record all dispatch invocations within one -// ApplyPlan call. +// ApplyPlan call. The driverErr field, when non-nil, is returned from +// ResourceDriver instead of the driver — used by tests that need to +// exercise the resolve-driver-error path. type fakeProvider struct { - driver *fakeDriver + driver *fakeDriver + driverErr error } func newFakeProvider() *fakeProvider { return &fakeProvider{driver: &fakeDriver{}} } -// calledReplace is true iff doReplace ran — Replace decomposes into -// Delete-then-Create on the driver, so we infer it from a Delete preceded -// by a Create on the same recorder, but for the dispatch smoke test we -// instead expose it via a derived helper. -func (p *fakeProvider) calledReplace() bool { return p.driver.calledDelete && p.driver.calledCreate } - func (p *fakeProvider) Name() string { return "fake" } func (p *fakeProvider) Version() string { return "0.0.0" } func (p *fakeProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } @@ -90,6 +93,9 @@ func (p *fakeProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces. return nil, nil } func (p *fakeProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + if p.driverErr != nil { + return nil, p.driverErr + } return p.driver, nil } func (p *fakeProvider) SupportedCanonicalKeys() []string { return nil } @@ -111,9 +117,11 @@ func state(name string) *interfaces.ResourceState { // TestApplyPlan_HandlesAllFourActions is the T3.1 dispatch smoke test: // one PlanAction of each kind (create/update/replace/delete) must reach -// the ResourceDriver. Bodies of doCreate/doUpdate/doReplace/doDelete are -// minimal stubs in T3.1 — full upsert / replace-id-propagation / etc. -// land in T3.2 / T3.3 / T3.4. This test only asserts dispatch wiring. +// the ResourceDriver. Asserts via integer call counts so a missing +// "case replace" arm in dispatchAction is detectable: a 4-action plan +// [create, update, replace, delete] must produce exactly 2 Create, 1 +// Update, 2 Delete. If Replace dispatch were silently dropped, those +// counts would shift to 1/1/1 and the assertion would fail. func TestApplyPlan_HandlesAllFourActions(t *testing.T) { plan := &interfaces.IaCPlan{ Actions: []interfaces.PlanAction{ @@ -131,9 +139,49 @@ func TestApplyPlan_HandlesAllFourActions(t *testing.T) { if len(result.Errors) != 0 { t.Errorf("expected no errors, got %v", result.Errors) } - if !fp.driver.calledCreate || !fp.driver.calledUpdate || !fp.calledReplace() || !fp.driver.calledDelete { - t.Errorf("not all action types invoked: create=%v update=%v replace=%v delete=%v", - fp.driver.calledCreate, fp.driver.calledUpdate, fp.calledReplace(), fp.driver.calledDelete) + // Replace = Delete + Create on the driver. + // Plan = [create, update, replace, delete] → + // Create: 1 (from create) + 1 (from replace) = 2 + // Update: 1 (from update) + // Delete: 1 (from delete) + 1 (from replace) = 2 + if got, want := fp.driver.createCount, 2; got != want { + t.Errorf("Create call count: got %d, want %d", got, want) + } + if got, want := fp.driver.updateCount, 1; got != want { + t.Errorf("Update call count: got %d, want %d", got, want) + } + if got, want := fp.driver.deleteCount, 2; got != want { + t.Errorf("Delete call count: got %d, want %d", got, want) + } +} + +// TestApplyPlan_ReplaceDispatchesViaDeleteThenCreate isolates Replace +// dispatch from explicit create/delete actions: a plan containing ONLY a +// single Replace action must produce exactly 1 Delete + 1 Create. If +// Replace were routed to default (unknown action) or to a different arm, +// these counts would not both be 1. +func TestApplyPlan_ReplaceDispatchesViaDeleteThenCreate(t *testing.T) { + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "replace", Resource: spec("c", "infra.vpc"), Current: state("c")}, + }, + } + fp := newFakeProvider() + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.Errors) != 0 { + t.Errorf("expected no errors, got %v", result.Errors) + } + if got, want := fp.driver.deleteCount, 1; got != want { + t.Errorf("Replace.deleteCount: got %d, want %d", got, want) + } + if got, want := fp.driver.createCount, 1; got != want { + t.Errorf("Replace.createCount: got %d, want %d", got, want) + } + if got, want := fp.driver.updateCount, 0; got != want { + t.Errorf("Replace.updateCount: got %d, want %d", got, want) } } @@ -175,3 +223,117 @@ func TestApplyPlan_PreservesPlanID(t *testing.T) { t.Errorf("PlanID: got %q want %q", result.PlanID, "plan-12345") } } + +// errResolveDriver is a sentinel for the ResourceDriver-error test path. +var errResolveDriver = errors.New("driver lookup failed") + +// TestApplyPlan_ResolveDriverErrorRecordsActionError covers the branch +// where p.ResourceDriver returns an error: the per-action error must be +// recorded with the canonical "resolve driver:" prefix and the loop must +// continue to the next action so a single bad type doesn't abort apply. +func TestApplyPlan_ResolveDriverErrorRecordsActionError(t *testing.T) { + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("bad", "infra.unknown")}, + {Action: "create", Resource: spec("good", "infra.vpc")}, + }, + } + fp := newFakeProvider() + fp.driverErr = errResolveDriver + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatalf("top-level error not expected for per-action driver-resolve failure; got %v", err) + } + // fp.driverErr applies to BOTH actions in this test (driver lookup + // hits the same fake), so both record the resolve-driver error. The + // important contracts are: the loop continued past action[0] (so + // len(Errors)==2 not 1), and the prefix is canonical. + if len(result.Errors) != 2 { + t.Fatalf("expected 2 per-action errors (loop must continue past action[0]); got %d (%v)", + len(result.Errors), result.Errors) + } + for i, e := range result.Errors { + if !strings.HasPrefix(e.Error, "resolve driver:") { + t.Errorf("Errors[%d].Error: want prefix %q; got %q", i, "resolve driver:", e.Error) + } + if e.Action != "create" { + t.Errorf("Errors[%d].Action: got %q want %q", i, e.Action, "create") + } + } + if result.Errors[0].Resource != "bad" || result.Errors[1].Resource != "good" { + t.Errorf("expected Resource ordering [bad, good]; got [%s, %s]", + result.Errors[0].Resource, result.Errors[1].Resource) + } +} + +// TestApplyPlan_LoopContinuesAfterPerActionFailure verifies the +// best-effort contract: action[0]'s driver lookup fails, action[1]'s +// resource type uses a different driver path that succeeds. Both effects +// must be observable in the result. +func TestApplyPlan_LoopContinuesAfterPerActionFailure(t *testing.T) { + // Use a per-call fakeProvider variant whose ResourceDriver returns + // an error for one type and the real driver for the other. + fp := &selectiveFakeProvider{ + fakeProvider: newFakeProvider(), + errorType: "infra.unknown", + } + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("bad", "infra.unknown")}, + {Action: "create", Resource: spec("good", "infra.vpc")}, + }, + } + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.Errors) != 1 { + t.Fatalf("expected 1 error from action[0]; got %d (%v)", len(result.Errors), result.Errors) + } + if fp.driver.createCount != 1 { + t.Errorf("action[1] should have reached the driver; createCount=%d want 1", fp.driver.createCount) + } + // Successful action's output should be in result.Resources. + if len(result.Resources) != 1 || result.Resources[0].Name != "good" { + t.Errorf("expected one Resources entry for action[1]; got %+v", result.Resources) + } +} + +// selectiveFakeProvider returns errResolveDriver for one resource type +// and the wrapped fakeProvider's driver for any other type. Used by +// TestApplyPlan_LoopContinuesAfterPerActionFailure to exercise mixed +// success/failure ordering across a multi-action plan. +type selectiveFakeProvider struct { + *fakeProvider + errorType string +} + +func (p *selectiveFakeProvider) ResourceDriver(typ string) (interfaces.ResourceDriver, error) { + if typ == p.errorType { + return nil, errResolveDriver + } + return p.driver, nil +} + +// TestApplyPlan_CtxCancellationStopsLoop verifies the loop respects +// context cancellation between actions. Drivers may honor ctx individually, +// but the loop itself must check at the iteration boundary so a +// long-running multi-action apply terminates promptly on Ctrl-C / deadline. +func TestApplyPlan_CtxCancellationStopsLoop(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before invocation so the very first iteration aborts. + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("a", "infra.vpc")}, + {Action: "create", Resource: spec("b", "infra.vpc")}, + }, + } + fp := newFakeProvider() + _, err := ApplyPlan(ctx, fp, plan) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled top-level error; got %v", err) + } + if fp.driver.createCount != 0 { + t.Errorf("no driver calls should run after ctx cancellation; createCount=%d", fp.driver.createCount) + } +} From 04d8ad2e5084ee96820b04ce2490e7bb471f25ab Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:10:42 -0400 Subject: [PATCH 25/38] fix(wfctl): drop unused crypto/sha256 + encoding/hex from infra_apply_plan_test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imports were left orphaned by W-1 PR #523 (commit 48f7a0c) when fingerprintForTest was switched to delegate to inputsnapshot.Compute instead of computing sha256 inline. cmd/wfctl test build was broken on HEAD because of the unused imports — surfaced while landing T3.1.5, which adds a new test file in the same package. Pure-mechanical cleanup. No behavior change. --- cmd/wfctl/infra_apply_plan_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/wfctl/infra_apply_plan_test.go b/cmd/wfctl/infra_apply_plan_test.go index ad72c5170..da950464c 100644 --- a/cmd/wfctl/infra_apply_plan_test.go +++ b/cmd/wfctl/infra_apply_plan_test.go @@ -2,8 +2,6 @@ package main import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "io" From f5a7ce9a1c8b16731c8fc4d405b0f087836a3d85 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:11:27 -0400 Subject: [PATCH 26/38] feat(iac): in-process apply unconditional drift postcondition (panic-safe + tolerant of mid-apply env unset) --- cmd/wfctl/infra_apply.go | 22 ++ cmd/wfctl/infra_apply_in_process_test.go | 127 ++++++++++ iac/wfctlhelpers/apply.go | 88 ++++++- iac/wfctlhelpers/apply_postcondition_test.go | 234 +++++++++++++++++++ 4 files changed, 465 insertions(+), 6 deletions(-) create mode 100644 cmd/wfctl/infra_apply_in_process_test.go create mode 100644 iac/wfctlhelpers/apply_postcondition_test.go diff --git a/cmd/wfctl/infra_apply.go b/cmd/wfctl/infra_apply.go index e1d7dd74c..94a32b93e 100644 --- a/cmd/wfctl/infra_apply.go +++ b/cmd/wfctl/infra_apply.go @@ -14,10 +14,32 @@ import ( "time" "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/iac/inputsnapshot" "github.com/GoCodeAlone/workflow/interfaces" "github.com/GoCodeAlone/workflow/platform" ) +// printDriftReportIfAny writes the canonical FormatStaleError output to w +// when result.InputDriftReport is non-empty. Safe to call when result is +// nil or InputDriftReport is empty/nil — both yield a no-op so callers +// don't need to defensively check the field before calling. +// +// Wired in by W-3a/T3.1.5 as a standalone helper; the actual call site in +// applyWithProviderAndStore (or its successor) lands when W-3b/T3.7 +// switches the in-process apply path through wfctlhelpers.ApplyPlan for +// v2 plugins. Until then this helper is exercised solely by the +// in-process drift test, NOT yet by any production caller — preserving +// the W-3a "zero runtime change for v1 plugins" invariant. +func printDriftReportIfAny(w io.Writer, result *interfaces.ApplyResult) { + if result == nil || len(result.InputDriftReport) == 0 { + return + } + // FormatStaleError emits the multi-line "plan stale: N input(s) changed" + // header + per-key fingerprint diff + trailing hint. Println adds the + // terminating newline so the next stderr line is not glued to the hint. + fmt.Fprintln(w, inputsnapshot.FormatStaleError(result.InputDriftReport)) +} + // infraApplyTroubleshootTimeout is the budget for a Troubleshoot call when // infra apply fails. Kept separate so tests can override it. var infraApplyTroubleshootTimeout = 30 * time.Second diff --git a/cmd/wfctl/infra_apply_in_process_test.go b/cmd/wfctl/infra_apply_in_process_test.go new file mode 100644 index 000000000..d45e95fd3 --- /dev/null +++ b/cmd/wfctl/infra_apply_in_process_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/iac/wfctlhelpers" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// fingerprintForTest is provided by infra_apply_plan_test.go in this +// package — it delegates to inputsnapshot.Compute so the production +// fingerprint algorithm is always used. Reused here to keep the +// in-process test consistent with the persisted-plan test. + +// inProcessFakeProvider satisfies interfaces.IaCProvider with no-op +// methods — TestApply_InProcess_PlanStaleDiagnostic_NamesChangedKeys +// exercises wfctlhelpers.ApplyPlan's drift postcondition independently +// of any per-action driver behavior. +type inProcessFakeProvider struct{} + +func (inProcessFakeProvider) Name() string { return "in-process-fake" } +func (inProcessFakeProvider) Version() string { return "0.0.0" } +func (inProcessFakeProvider) Initialize(_ context.Context, _ map[string]any) error { return nil } +func (inProcessFakeProvider) Capabilities() []interfaces.IaCCapabilityDeclaration { return nil } +func (inProcessFakeProvider) Plan(_ context.Context, _ []interfaces.ResourceSpec, _ []interfaces.ResourceState) (*interfaces.IaCPlan, error) { + return &interfaces.IaCPlan{}, nil +} +func (inProcessFakeProvider) Apply(_ context.Context, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + return &interfaces.ApplyResult{}, nil +} +func (inProcessFakeProvider) Destroy(_ context.Context, _ []interfaces.ResourceRef) (*interfaces.DestroyResult, error) { + return nil, nil +} +func (inProcessFakeProvider) Status(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) { + return nil, nil +} +func (inProcessFakeProvider) DetectDrift(_ context.Context, _ []interfaces.ResourceRef) ([]interfaces.DriftResult, error) { + return nil, nil +} +func (inProcessFakeProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) { + return nil, nil +} +func (inProcessFakeProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { + return nil, nil +} +func (inProcessFakeProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return nil, nil +} +func (inProcessFakeProvider) SupportedCanonicalKeys() []string { return nil } +func (inProcessFakeProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) { + return nil, nil +} +func (inProcessFakeProvider) Close() error { return nil } + +// TestApply_InProcess_PlanStaleDiagnostic_NamesChangedKeys validates the +// full T3.1.5 chain: plan-time env value → wfctlhelpers.ApplyPlan +// captures InitialInputSnapshot at entry, then the deferred postcondition +// computes drift against the apply-time snapshot, populating +// result.InputDriftReport. cmd/wfctl/printDriftReportIfAny then formats +// the report onto stderr in the canonical FormatStaleError shape. +// +// This is the in-process counterpart to the persisted-`--plan` path +// covered by cmd/wfctl/infra.go (W-1/T1.5). Once W-3b/T3.7 wires +// wfctlhelpers.ApplyPlan into the in-process apply call site, this test +// exercises exactly the path operators run during `wfctl infra apply` +// against a v2-conformant IaCProvider plugin. +func TestApply_InProcess_PlanStaleDiagnostic_NamesChangedKeys(t *testing.T) { + const varName = "WFCTL_T315_INPROC_PASSWORD" + planFP := fingerprintForTest("old-value") + t.Setenv(varName, "new-value") // post-plan env value differs from plan-time fingerprint + plan := &interfaces.IaCPlan{ + InputSnapshot: map[string]string{varName: planFP}, + } + result, err := wfctlhelpers.ApplyPlan(context.Background(), inProcessFakeProvider{}, plan) + if err != nil { + t.Fatalf("ApplyPlan returned top-level error: %v", err) + } + // Postcondition assertion: exactly one entry naming the changed key. + if len(result.InputDriftReport) != 1 { + t.Fatalf("expected 1 drift entry; got %d (%+v)", + len(result.InputDriftReport), result.InputDriftReport) + } + if got := result.InputDriftReport[0].Name; got != varName { + t.Errorf("expected drift on %q; got %q", varName, got) + } + + // cmd/wfctl wiring assertion: printDriftReportIfAny formats the + // report onto the supplied writer in the canonical shape. + var buf bytes.Buffer + printDriftReportIfAny(&buf, result) + out := buf.String() + if !strings.Contains(out, "plan stale:") { + t.Errorf("expected canonical stale-error header in output; got %q", out) + } + if !strings.Contains(out, varName) { + t.Errorf("expected drift output to name %q; got %q", varName, out) + } + if !strings.Contains(out, "hint: ensure all env vars referenced") { + t.Errorf("expected canonical hint line in output; got %q", out) + } +} + +// TestPrintDriftReportIfAny_NoOpOnEmpty locks the no-op contract so +// callers don't need to guard the call: nil result, nil report, and +// empty-but-non-nil report all produce zero output. +func TestPrintDriftReportIfAny_NoOpOnEmpty(t *testing.T) { + cases := []struct { + name string + result *interfaces.ApplyResult + }{ + {"nil-result", nil}, + {"nil-report", &interfaces.ApplyResult{}}, + {"empty-report", &interfaces.ApplyResult{InputDriftReport: []interfaces.DriftEntry{}}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var buf bytes.Buffer + printDriftReportIfAny(&buf, c.result) + if buf.Len() != 0 { + t.Errorf("expected no output for %s; got %q", c.name, buf.String()) + } + }) + } +} diff --git a/iac/wfctlhelpers/apply.go b/iac/wfctlhelpers/apply.go index 663e6eef5..2d3c3c9cb 100644 --- a/iac/wfctlhelpers/apply.go +++ b/iac/wfctlhelpers/apply.go @@ -21,7 +21,9 @@ package wfctlhelpers import ( "context" "fmt" + "log" + "github.com/GoCodeAlone/workflow/iac/inputsnapshot" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -33,21 +35,79 @@ import ( // at the next iteration boundary and returns ctx.Err() as the top-level // error so a long apply terminates promptly on Ctrl-C / SIGTERM. // +// At entry ApplyPlan captures result.InitialInputSnapshot by fingerprinting +// every name listed in plan.InputSnapshot through the OS env. After the +// dispatch loop completes — successfully or not — a deferred postcondition +// computes result.InputDriftReport against an apply-time snapshot taken +// through inputsnapshot.NewTolerantEnvProvider (sub-action env unsets are +// preserved, not flagged as drift). The postcondition is wrapped in +// recover() so a buggy env-provider closure cannot corrupt apply results; +// on panic, InputDriftReport is reset to nil and a warning is logged. +// // The function is concurrency-safe with respect to its inputs: result is // owned by ApplyPlan for the duration of the call and is not shared with // the provider or driver implementations. // -// T3.1 ships the dispatch skeleton. T3.1.5 wraps the body with the input- -// drift postcondition; T3.2/T3.3/T3.4 fill the per-action sub-functions -// with their full bodies. Test smoke-coverage verifies dispatch wiring; -// per-action behavior is exercised by the per-task test files. +// T3.1 ships the dispatch skeleton; T3.1.5 added the postcondition above; +// T3.2/T3.3/T3.4 fill the per-action sub-functions with their full bodies. func ApplyPlan(ctx context.Context, p interfaces.IaCProvider, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { - result := &interfaces.ApplyResult{PlanID: plan.ID} + return applyPlanWithEnvProvider(ctx, p, plan, nil) +} + +// applyPlanWithEnvProvider is the same-package test seam used by +// apply_postcondition_test.go to inject a custom apply-time env provider +// into the deferred drift postcondition (e.g., a panicky closure that +// stresses the recover() guard). When applyTimeEnv is nil, the function +// uses inputsnapshot.NewTolerantEnvProvider(plan.InputSnapshot) — the +// production behavior. The seam stays unexported because the only +// sanctioned external entry point is ApplyPlan. +func applyPlanWithEnvProvider( + ctx context.Context, + p interfaces.IaCProvider, + plan *interfaces.IaCPlan, + applyTimeEnv func(string) (string, bool), +) (*interfaces.ApplyResult, error) { + inputNames := snapshotKeys(plan.InputSnapshot) + result := &interfaces.ApplyResult{ + PlanID: plan.ID, + InitialInputSnapshot: inputsnapshot.Snapshot(inputNames, inputsnapshot.OSEnvProvider), + } + + // Deferred drift postcondition — runs unconditionally (success OR + // per-action failure), wrapped in recover() so a buggy env-provider + // closure (e.g., one freed mid-flight) cannot corrupt the apply + // result. On panic, drop the report rather than ship a partial one. + defer func() { + defer func() { + if r := recover(); r != nil { + result.InputDriftReport = nil + log.Printf("warning: input-drift postcondition panicked: %v", r) + } + }() + // Resolve the apply-time env provider lazily so the production + // path's NewTolerantEnvProvider closure isn't constructed when a + // test override is in play. + env := applyTimeEnv + if env == nil { + // CRITICAL: factory MUST be invoked here, NOT passed by + // reference (NewTolerantEnvProvider returns the closure; + // passing the function value would call the factory itself + // every Snapshot call and short-circuit the planSnapshot + // closure-capture). The cycle-4 reviewer caught this exact + // bug-class in the rev3 pseudo-code. + env = inputsnapshot.NewTolerantEnvProvider(plan.InputSnapshot) + } + applyTimeSnap := inputsnapshot.Snapshot(inputNames, env) + result.InputDriftReport = inputsnapshot.ComputeDrift(plan.InputSnapshot, applyTimeSnap) + }() + for _, action := range plan.Actions { // Honor cancellation at the loop boundary. Drivers should also // check ctx internally for in-flight work, but the loop check // guarantees apply stops between actions even if a driver - // happens to ignore ctx. + // happens to ignore ctx. The deferred postcondition still runs + // on early return so InputDriftReport is populated even on a + // canceled apply. if err := ctx.Err(); err != nil { return result, err } @@ -71,6 +131,22 @@ func ApplyPlan(ctx context.Context, p interfaces.IaCProvider, plan *interfaces.I return result, nil } +// snapshotKeys returns the keys of m as an unordered slice. ComputeDrift +// sorts its output, and Snapshot iterates in any order, so no key sort +// is needed at this stage. Inlined helper to keep the dependency +// surface minimal and avoid pulling in slices/maps at the postcondition +// call site. +func snapshotKeys(m map[string]string) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + // dispatchAction routes a single PlanAction to the per-kind sub-function. // An unknown action kind returns an error which ApplyPlan records on // result.Errors so an operator running a malformed plan sees a per-action diff --git a/iac/wfctlhelpers/apply_postcondition_test.go b/iac/wfctlhelpers/apply_postcondition_test.go new file mode 100644 index 000000000..9a3ebcd44 --- /dev/null +++ b/iac/wfctlhelpers/apply_postcondition_test.go @@ -0,0 +1,234 @@ +package wfctlhelpers + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// fingerprint returns the canonical 16-hex-char sha256 prefix used by +// inputsnapshot.Compute. Local helper so tests can build plan +// InputSnapshot fixtures with realistic-looking values without coupling +// to the inputsnapshot package's unexported constants. +func fingerprint(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:])[:16] +} + +// TestApplyPlan_InitialInputSnapshot_CapturedAtEntry verifies that +// ApplyPlan populates ApplyResult.InitialInputSnapshot at apply entry by +// fingerprinting every name listed in plan.InputSnapshot through the +// real OS env. T3.1.5 lands this capture; T3.1's skeleton did not. +func TestApplyPlan_InitialInputSnapshot_CapturedAtEntry(t *testing.T) { + t.Setenv("WFCTL_T315_FOO", "value-foo") + t.Setenv("WFCTL_T315_BAR", "value-bar") + plan := &interfaces.IaCPlan{ + InputSnapshot: map[string]string{ + "WFCTL_T315_FOO": fingerprint("value-foo"), + "WFCTL_T315_BAR": fingerprint("value-bar"), + }, + } + fp := newFakeProvider() + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if got, want := result.InitialInputSnapshot["WFCTL_T315_FOO"], fingerprint("value-foo"); got != want { + t.Errorf("InitialInputSnapshot[WFCTL_T315_FOO]: got %q want %q", got, want) + } + if got, want := result.InitialInputSnapshot["WFCTL_T315_BAR"], fingerprint("value-bar"); got != want { + t.Errorf("InitialInputSnapshot[WFCTL_T315_BAR]: got %q want %q", got, want) + } +} + +// TestApply_Postcondition_PanicDoesNotCorruptResult verifies that a +// panic inside the deferred postcondition (e.g., a buggy env-provider +// closure) does not crash apply or surface as the top-level error. +// Recovers and clears result.InputDriftReport so an operator sees an +// empty drift report rather than a partially-populated one. Uses the +// internal seam applyPlanWithEnvProvider to inject the panicky provider. +func TestApply_Postcondition_PanicDoesNotCorruptResult(t *testing.T) { + panickyEnv := func(_ string) (string, bool) { panic("env-provider closure freed") } + plan := &interfaces.IaCPlan{ + InputSnapshot: map[string]string{"FOO": fingerprint("v")}, + } + fp := newFakeProvider() + result, err := applyPlanWithEnvProvider(context.Background(), fp, plan, panickyEnv) + if err != nil { + t.Fatalf("apply should not surface postcondition panic: %v", err) + } + if result.InputDriftReport != nil { + t.Errorf("on postcondition panic, drift report should be nil; got %+v", result.InputDriftReport) + } +} + +// TestApply_Postcondition_FingerprintAfterEnvUnset_NoFalsePositive +// covers the cycle-3 sub-action cleanup case: an apply action unsets a +// credential env var (e.g., the post-apply security hardening pattern). +// The postcondition must NOT flag this as drift — the operator's mental +// model says the value at plan time is what matters; an unset post-apply +// is not "the env changed mid-flight" in the user-facing sense. The +// production NewTolerantEnvProvider preserves fingerprints for +// plan-time-set apply-time-unset vars via the in-package sentinel; this +// test exercises that path end-to-end through ApplyPlan. +func TestApply_Postcondition_FingerprintAfterEnvUnset_NoFalsePositive(t *testing.T) { + const varName = "WFCTL_T315_PG_PASSWORD" + t.Setenv(varName, "value") + planFP := fingerprint("value") + plan := &interfaces.IaCPlan{ + InputSnapshot: map[string]string{varName: planFP}, + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("a", "infra.vpc")}, + }, + } + // envUnsetingFakeProvider unsets the env var inside Create, simulating + // a sub-action cleanup that happens between snapshot capture and + // postcondition execution. + fp := &envUnsetingFakeProvider{ + fakeProvider: newFakeProvider(), + varToUnset: varName, + } + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.InputDriftReport) != 0 { + t.Errorf("post-apply env-unset must not trigger drift false-positive; got: %+v", result.InputDriftReport) + } + // Sanity: the sub-action did unset the var. + if _, set := os.LookupEnv(varName); set { + t.Errorf("test setup: sub-action did not unset %s", varName) + } +} + +// envUnsetingFakeProvider returns a fake driver whose Create method +// unsets a configured env var as a side-effect, simulating the +// sub-action credential-cleanup pattern that the tolerant env provider +// must accommodate without false-positive drift. +type envUnsetingFakeProvider struct { + *fakeProvider + varToUnset string +} + +func (p *envUnsetingFakeProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return &envUnsetingFakeDriver{fakeDriver: p.driver, varToUnset: p.varToUnset}, nil +} + +type envUnsetingFakeDriver struct { + *fakeDriver + varToUnset string +} + +func (d *envUnsetingFakeDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + out, err := d.fakeDriver.Create(ctx, spec) + if err != nil { + return out, err + } + _ = os.Unsetenv(d.varToUnset) + return out, nil +} + +// TestApplyPlan_PlanStaleDiagnostic_NamesChangedKeys is the canonical +// drift-detection test: an env var captured at plan time has a +// different value at apply time. The postcondition must record exactly +// one drift entry naming the changed key, with both fingerprints +// distinct. +func TestApplyPlan_PlanStaleDiagnostic_NamesChangedKeys(t *testing.T) { + const varName = "WFCTL_T315_STAGING_PG_PASSWORD" + planFP := fingerprint("old-value") + t.Setenv(varName, "new-value") // post-plan env value differs from plan-time fingerprint + plan := &interfaces.IaCPlan{ + InputSnapshot: map[string]string{varName: planFP}, + } + fp := newFakeProvider() + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.InputDriftReport) != 1 { + t.Fatalf("expected 1 drift entry, got %d (%+v)", len(result.InputDriftReport), result.InputDriftReport) + } + got := result.InputDriftReport[0] + if got.Name != varName { + t.Errorf("Name: got %q want %q", got.Name, varName) + } + if got.PlanFingerprint != planFP { + t.Errorf("PlanFingerprint: got %q want %q", got.PlanFingerprint, planFP) + } + if got.ApplyFingerprint == planFP || got.ApplyFingerprint == "" { + t.Errorf("ApplyFingerprint should be a distinct, non-empty value; got %q", got.ApplyFingerprint) + } +} + +// TestApplyPlan_NoDriftWhenInputSnapshotEmpty verifies the no-op case: +// a plan with no InputSnapshot must produce no drift report. Avoids +// the postcondition incorrectly synthesizing entries for vars that +// were never tracked. +func TestApplyPlan_NoDriftWhenInputSnapshotEmpty(t *testing.T) { + plan := &interfaces.IaCPlan{} + fp := newFakeProvider() + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.InputDriftReport) != 0 { + t.Errorf("empty InputSnapshot must yield empty drift; got %+v", result.InputDriftReport) + } +} + +// errFromDispatch is a sentinel for the "apply errored AND drift +// detected" interleave test. Confirms the deferred postcondition runs +// regardless of dispatch outcome. +var errFromDispatch = errors.New("dispatch failure (test)") + +// TestApplyPlan_PostconditionRunsEvenIfDispatchErrored verifies the +// "regardless of apply success/error path" contract on the +// InputDriftReport godoc. A driver that returns an error must still +// allow drift detection to populate the report — the postcondition is +// strictly best-effort but unconditional. +func TestApplyPlan_PostconditionRunsEvenIfDispatchErrored(t *testing.T) { + const varName = "WFCTL_T315_DRIFT_DURING_FAIL" + planFP := fingerprint("plan-time") + t.Setenv(varName, "apply-time") + plan := &interfaces.IaCPlan{ + InputSnapshot: map[string]string{varName: planFP}, + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("a", "infra.vpc")}, + }, + } + fp := &erroringFakeProvider{fakeProvider: newFakeProvider()} + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.Errors) != 1 { + t.Errorf("expected per-action error from erroring driver; got %d (%v)", len(result.Errors), result.Errors) + } + if len(result.InputDriftReport) != 1 { + t.Errorf("postcondition must populate drift report even when dispatch errors; got %+v", result.InputDriftReport) + } +} + +// erroringFakeProvider returns a driver whose Create returns +// errFromDispatch so apply produces a per-action error. +type erroringFakeProvider struct { + *fakeProvider +} + +func (p *erroringFakeProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return &erroringFakeDriver{fakeDriver: p.driver}, nil +} + +type erroringFakeDriver struct { + *fakeDriver +} + +func (d *erroringFakeDriver) Create(_ context.Context, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.fakeDriver.createCount++ + return nil, errFromDispatch +} From 0c30eec4deff16ac9e17a4e77fde223d88f3a723 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:14:11 -0400 Subject: [PATCH 27/38] feat(iac): doCreate honors UpsertSupporter for ErrResourceAlreadyExists recovery --- iac/wfctlhelpers/apply.go | 45 +++++- iac/wfctlhelpers/apply_create_test.go | 209 ++++++++++++++++++++++++++ interfaces/iac_resource_driver.go | 20 +++ 3 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 iac/wfctlhelpers/apply_create_test.go diff --git a/iac/wfctlhelpers/apply.go b/iac/wfctlhelpers/apply.go index 2d3c3c9cb..1d95b4229 100644 --- a/iac/wfctlhelpers/apply.go +++ b/iac/wfctlhelpers/apply.go @@ -20,6 +20,7 @@ package wfctlhelpers import ( "context" + "errors" "fmt" "log" @@ -166,18 +167,48 @@ func dispatchAction(ctx context.Context, d interfaces.ResourceDriver, action int } } -// doCreate is the T3.1 skeleton — invokes Create and appends the output -// to result.Resources on success. T3.2 will replace this with the full -// UpsertSupporter recovery path on ErrResourceAlreadyExists. +// doCreate invokes Create and, on ErrResourceAlreadyExists, attempts an +// idempotent upsert recovery for drivers that opt in via the +// UpsertSupporter interface. The recovery path is: +// +// 1. Probe the driver for UpsertSupporter — if absent or +// SupportsUpsert()==false, the original conflict surfaces unchanged +// and downstream callers see ErrResourceAlreadyExists in +// result.Errors. +// 2. Read the existing resource by Name + Type (no ProviderID — the +// driver's Read is responsible for locating by name when ProviderID +// is empty; SupportsUpsert()==true is the contract that this works). +// 3. Defensive: if Read returns an empty ProviderID, fail with a named +// diagnostic — Update with an empty ProviderID would route to a +// create-by-spec path on most drivers, defeating the upsert. +// 4. Update the existing resource with the desired spec, threading +// the ProviderID found in step 2. +// +// Read-after-conflict failures wrap both the original Create error and +// the Read error via errors.Join, so callers can match either via +// errors.Is. func doCreate(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult) error { out, err := d.Create(ctx, action.Resource) - if err != nil { - return err + if errors.Is(err, interfaces.ErrResourceAlreadyExists) { + us, ok := d.(interfaces.UpsertSupporter) + if !ok || !us.SupportsUpsert() { + return err // no recovery available; surface the conflict + } + ref := interfaces.ResourceRef{Name: action.Resource.Name, Type: action.Resource.Type} + existing, readErr := d.Read(ctx, ref) + if readErr != nil { + return fmt.Errorf("upsert: read after conflict: %w", errors.Join(err, readErr)) + } + if existing == nil || existing.ProviderID == "" { + return fmt.Errorf("upsert: resource %q found by name but ProviderID is empty: %w", ref.Name, err) + } + ref.ProviderID = existing.ProviderID + out, err = d.Update(ctx, ref, action.Resource) } - if out != nil { + if err == nil && out != nil { result.Resources = append(result.Resources, *out) } - return nil + return err } // doUpdate is the T3.1 skeleton — invokes Update with a ResourceRef diff --git a/iac/wfctlhelpers/apply_create_test.go b/iac/wfctlhelpers/apply_create_test.go new file mode 100644 index 000000000..c530b712e --- /dev/null +++ b/iac/wfctlhelpers/apply_create_test.go @@ -0,0 +1,209 @@ +package wfctlhelpers + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// fakeDriverWithUpsert is a ResourceDriver test double that: +// - returns createErr from Create (typically ErrResourceAlreadyExists) +// - returns readResult / readErr from Read +// - records whether Update was called via updateCalled +// - implements UpsertSupporter when supportsUpsert is true +// +// Used to exercise doCreate's upsert-recovery path. Embedded fakeDriver +// supplies the no-op other methods so the recorder is minimal. +type fakeDriverWithUpsert struct { + *fakeDriver + createErr error + readResult *interfaces.ResourceOutput + readErr error + updateCalled bool + updateOut *interfaces.ResourceOutput + supportsUpsert bool +} + +func (d *fakeDriverWithUpsert) Create(_ context.Context, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.fakeDriver.createCount++ + if d.createErr != nil { + return nil, d.createErr + } + return &interfaces.ResourceOutput{}, nil +} + +func (d *fakeDriverWithUpsert) Read(_ context.Context, _ interfaces.ResourceRef) (*interfaces.ResourceOutput, error) { + if d.readErr != nil { + return nil, d.readErr + } + return d.readResult, nil +} + +func (d *fakeDriverWithUpsert) Update(_ context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.updateCalled = true + d.fakeDriver.updateCount++ + if d.updateOut != nil { + return d.updateOut, nil + } + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: ref.ProviderID}, nil +} + +func (d *fakeDriverWithUpsert) SupportsUpsert() bool { return d.supportsUpsert } + +// upsertFakeProvider returns the fakeDriverWithUpsert from ResourceDriver +// regardless of resource type. +type upsertFakeProvider struct { + *fakeProvider + upsert *fakeDriverWithUpsert +} + +func newUpsertFakeProvider(d *fakeDriverWithUpsert) *upsertFakeProvider { + if d.fakeDriver == nil { + d.fakeDriver = &fakeDriver{} + } + return &upsertFakeProvider{fakeProvider: newFakeProvider(), upsert: d} +} + +func (p *upsertFakeProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return p.upsert, nil +} + +// TestApplyPlan_Create_UpsertOnAlreadyExists is the canonical T3.2 test: +// a Create that returns ErrResourceAlreadyExists must trigger +// Read + Update on a driver that opts in via UpsertSupporter. The +// recovery must clear the per-action error so result.Errors is empty. +func TestApplyPlan_Create_UpsertOnAlreadyExists(t *testing.T) { + d := &fakeDriverWithUpsert{ + createErr: interfaces.ErrResourceAlreadyExists, + readResult: &interfaces.ResourceOutput{ProviderID: "found-uuid"}, + supportsUpsert: true, + } + fp := newUpsertFakeProvider(d) + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("a", "infra.vpc")}, + }} + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.Errors) > 0 { + t.Errorf("upsert should recover; got errors: %v", result.Errors) + } + if !d.updateCalled { + t.Errorf("upsert path should call Update after Read; updateCalled=%v", d.updateCalled) + } +} + +// TestApplyPlan_Create_AlreadyExists_NoUpsertSupport verifies the +// fall-through: if the driver does NOT implement UpsertSupporter (or +// returns SupportsUpsert()==false), the original ErrResourceAlreadyExists +// surfaces unchanged via result.Errors. No Read or Update happens. +func TestApplyPlan_Create_AlreadyExists_NoUpsertSupport(t *testing.T) { + d := &fakeDriverWithUpsert{ + createErr: interfaces.ErrResourceAlreadyExists, + supportsUpsert: false, + } + fp := newUpsertFakeProvider(d) + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("a", "infra.vpc")}, + }} + result, _ := ApplyPlan(context.Background(), fp, plan) + if len(result.Errors) != 1 { + t.Fatalf("expected 1 per-action error; got %d (%v)", len(result.Errors), result.Errors) + } + if !strings.Contains(result.Errors[0].Error, "already exists") { + t.Errorf("expected ErrResourceAlreadyExists in error; got %q", result.Errors[0].Error) + } + if d.updateCalled { + t.Errorf("Update must not be called when SupportsUpsert returns false") + } +} + +// TestApplyPlan_Create_UpsertReadFailureWraps verifies the diagnostic +// when Read fails after an ErrResourceAlreadyExists conflict: the error +// wraps both the original create error and the read error so callers +// can match either via errors.Is. +func TestApplyPlan_Create_UpsertReadFailureWraps(t *testing.T) { + readErr := errors.New("read failed: 503") + d := &fakeDriverWithUpsert{ + createErr: interfaces.ErrResourceAlreadyExists, + readErr: readErr, + supportsUpsert: true, + } + fp := newUpsertFakeProvider(d) + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("a", "infra.vpc")}, + }} + result, _ := ApplyPlan(context.Background(), fp, plan) + if len(result.Errors) != 1 { + t.Fatalf("expected 1 error; got %d (%v)", len(result.Errors), result.Errors) + } + got := result.Errors[0].Error + if !strings.Contains(got, "upsert: read after conflict") { + t.Errorf("expected canonical 'upsert: read after conflict' prefix; got %q", got) + } + if !strings.Contains(got, "503") { + t.Errorf("expected wrapped read error in message; got %q", got) + } + if d.updateCalled { + t.Errorf("Update must not be called when Read fails") + } +} + +// TestApplyPlan_Create_UpsertEmptyProviderIDFails verifies a defensive +// check: if Read finds a resource by name but its ProviderID is empty, +// the upsert path must NOT call Update with an empty ProviderID (which +// would route to a Create-by-spec semantics). Instead, fail with a +// diagnostic that names the resource. +func TestApplyPlan_Create_UpsertEmptyProviderIDFails(t *testing.T) { + d := &fakeDriverWithUpsert{ + createErr: interfaces.ErrResourceAlreadyExists, + readResult: &interfaces.ResourceOutput{ProviderID: ""}, // defensive: empty ID + supportsUpsert: true, + } + fp := newUpsertFakeProvider(d) + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("vpc-1", "infra.vpc")}, + }} + result, _ := ApplyPlan(context.Background(), fp, plan) + if len(result.Errors) != 1 { + t.Fatalf("expected 1 error; got %d (%v)", len(result.Errors), result.Errors) + } + got := result.Errors[0].Error + if !strings.Contains(got, "ProviderID is empty") { + t.Errorf("expected 'ProviderID is empty' in diagnostic; got %q", got) + } + if !strings.Contains(got, "vpc-1") { + t.Errorf("diagnostic should name the resource; got %q", got) + } + if d.updateCalled { + t.Errorf("Update must not be called with empty ProviderID") + } +} + +// TestApplyPlan_Create_UpsertAppendsResources verifies happy-path +// plumbing: a successful upsert recovery must also append the Update's +// output to result.Resources so downstream state-write paths see the +// recovered resource exactly as if Create had succeeded directly. +func TestApplyPlan_Create_UpsertAppendsResources(t *testing.T) { + d := &fakeDriverWithUpsert{ + createErr: interfaces.ErrResourceAlreadyExists, + readResult: &interfaces.ResourceOutput{ProviderID: "found-uuid"}, + updateOut: &interfaces.ResourceOutput{Name: "vpc-1", Type: "infra.vpc", ProviderID: "found-uuid", Status: "active"}, + supportsUpsert: true, + } + fp := newUpsertFakeProvider(d) + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("vpc-1", "infra.vpc")}, + }} + result, _ := ApplyPlan(context.Background(), fp, plan) + if len(result.Resources) != 1 { + t.Fatalf("expected 1 resource appended; got %d (%+v)", len(result.Resources), result.Resources) + } + if got := result.Resources[0].ProviderID; got != "found-uuid" { + t.Errorf("ProviderID: got %q want %q", got, "found-uuid") + } +} diff --git a/interfaces/iac_resource_driver.go b/interfaces/iac_resource_driver.go index 0efd2a369..058ceab0f 100644 --- a/interfaces/iac_resource_driver.go +++ b/interfaces/iac_resource_driver.go @@ -40,6 +40,26 @@ type ResourceAdoptionLocator interface { AdoptionRef(spec ResourceSpec) (ResourceRef, bool, error) } +// UpsertSupporter is an optional interface implemented by ResourceDrivers +// that support locating an existing resource by name alone (empty +// ProviderID) in their Read method. wfctlhelpers.ApplyPlan uses this hook +// to recover from Create that returns ErrResourceAlreadyExists: when the +// driver opts in via SupportsUpsert()==true, ApplyPlan calls Read with a +// name-only ResourceRef to obtain the existing ProviderID, then calls +// Update to bring the resource to the desired state — net effect of an +// idempotent upsert without requiring drivers to implement upsert +// natively. +// +// Drivers that do not implement this interface (or return false) yield +// the original ErrResourceAlreadyExists unchanged — ApplyPlan does NOT +// silently swallow the conflict. Implementations should return true only +// when their Read can locate a resource by Name + Type without a +// ProviderID; returning true while requiring a non-empty ProviderID in +// Read defeats the recovery path. +type UpsertSupporter interface { + SupportsUpsert() bool +} + // ResourceOutput is the concrete output of a provisioned or read resource. type ResourceOutput struct { Name string `json:"name"` From a3fc98bbd8260f708657ccba40eb21ec6cdd0735 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:16:27 -0400 Subject: [PATCH 28/38] feat(iac): doUpdate + doDelete actions --- iac/wfctlhelpers/apply.go | 30 ++- iac/wfctlhelpers/apply_update_delete_test.go | 220 +++++++++++++++++++ 2 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 iac/wfctlhelpers/apply_update_delete_test.go diff --git a/iac/wfctlhelpers/apply.go b/iac/wfctlhelpers/apply.go index 1d95b4229..e3850320d 100644 --- a/iac/wfctlhelpers/apply.go +++ b/iac/wfctlhelpers/apply.go @@ -211,10 +211,18 @@ func doCreate(ctx context.Context, d interfaces.ResourceDriver, action interface return err } -// doUpdate is the T3.1 skeleton — invokes Update with a ResourceRef -// derived from action.Current's ProviderID (when present) and appends the -// output to result.Resources on success. T3.3 will fill in the typed -// pre-condition checks. +// doUpdate invokes Update with a ResourceRef carrying action.Current's +// ProviderID (when action.Current is non-nil), appending the driver's +// returned ResourceOutput to result.Resources on success. Driver errors +// pass through unchanged so the caller's per-action error wrapper +// (ApplyPlan's loop body) records them with the canonical action + +// resource fields. +// +// Defensive contract: doUpdate does NOT synthesize a precondition error +// when action.Current is nil — the driver is the authority on what an +// empty ProviderID means. ComputePlan upstream is responsible for never +// emitting an Update without action.Current; if it does, the driver's +// own typed validation surfaces the bug. func doUpdate(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult) error { ref := refFromAction(action) out, err := d.Update(ctx, ref, action.Resource) @@ -244,9 +252,17 @@ func doReplace(ctx context.Context, d interfaces.ResourceDriver, action interfac return nil } -// doDelete is the T3.1 skeleton — invokes Delete. T3.3 fills in the typed -// pre-condition checks; this skeleton already closes the latent gap noted -// in the design (DOProvider.Apply has no "case delete" today). +// doDelete invokes Delete with a ResourceRef carrying action.Current's +// ProviderID. This closes the latent gap documented in the design +// (DOProvider.Apply has no "case delete" arm today, so wfctl's +// state-prune action silently skipped cloud-resource deletion through +// the v1 dispatch path); under v2 dispatch wfctlhelpers.ApplyPlan +// always invokes the driver's Delete, ensuring state-prune is paired +// with a real cloud-side mutation. +// +// Driver errors pass through unchanged for the caller's per-action +// error wrapping. doDelete does not append to result.Resources — a +// successful delete has no resource to record. func doDelete(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction) error { return d.Delete(ctx, refFromAction(action)) } diff --git a/iac/wfctlhelpers/apply_update_delete_test.go b/iac/wfctlhelpers/apply_update_delete_test.go new file mode 100644 index 000000000..527f67c67 --- /dev/null +++ b/iac/wfctlhelpers/apply_update_delete_test.go @@ -0,0 +1,220 @@ +package wfctlhelpers + +import ( + "context" + "errors" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// providerIDCapturingDriver records the ResourceRef passed to Update / +// Delete so tests can assert ProviderID propagation from action.Current +// to the driver call. +type providerIDCapturingDriver struct { + *fakeDriver + updateRef interfaces.ResourceRef + updateSpec interfaces.ResourceSpec + deleteRef interfaces.ResourceRef + deleteErr error + updateErr error +} + +func (d *providerIDCapturingDriver) Update(_ context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.fakeDriver.updateCount++ + d.updateRef = ref + d.updateSpec = spec + if d.updateErr != nil { + return nil, d.updateErr + } + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: ref.ProviderID}, nil +} + +func (d *providerIDCapturingDriver) Delete(_ context.Context, ref interfaces.ResourceRef) error { + d.fakeDriver.deleteCount++ + d.deleteRef = ref + return d.deleteErr +} + +// captureFakeProvider returns the providerIDCapturingDriver for any +// resource type so the caller can assert what ApplyPlan passed through. +type captureFakeProvider struct { + *fakeProvider + driver *providerIDCapturingDriver +} + +func newCaptureFakeProvider() *captureFakeProvider { + base := newFakeProvider() + return &captureFakeProvider{ + fakeProvider: base, + driver: &providerIDCapturingDriver{fakeDriver: base.driver}, + } +} + +func (p *captureFakeProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return p.driver, nil +} + +// TestApplyPlan_Update_PassesProviderID is the canonical T3.3 update +// test: the plan's update action carries action.Current with a known +// ProviderID; doUpdate must thread that ProviderID into the +// ResourceRef passed to driver.Update so the driver knows which +// resource to mutate. Threading it via Name alone would lose the +// upstream ID and force the driver to re-resolve. +func TestApplyPlan_Update_PassesProviderID(t *testing.T) { + const knownID = "do-vpc-abc123" + cur := &interfaces.ResourceState{ + Name: "vpc-1", + Type: "infra.vpc", + ProviderID: knownID, + } + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "update", Resource: spec("vpc-1", "infra.vpc"), Current: cur}, + }, + } + fp := newCaptureFakeProvider() + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.Errors) != 0 { + t.Fatalf("unexpected errors: %v", result.Errors) + } + if got := fp.driver.updateRef.ProviderID; got != knownID { + t.Errorf("Update ResourceRef.ProviderID: got %q want %q", got, knownID) + } + if got := fp.driver.updateRef.Name; got != "vpc-1" { + t.Errorf("Update ResourceRef.Name: got %q want %q", got, "vpc-1") + } + if got := fp.driver.updateRef.Type; got != "infra.vpc" { + t.Errorf("Update ResourceRef.Type: got %q want %q", got, "infra.vpc") + } + // Driver returns a populated ResourceOutput; ApplyPlan must thread + // it through to result.Resources. + if len(result.Resources) != 1 || result.Resources[0].ProviderID != knownID { + t.Errorf("Resources: expected one entry with ProviderID %q; got %+v", knownID, result.Resources) + } +} + +// TestApplyPlan_Update_NilCurrentIsHandledDefensively verifies the +// edge case where a malformed plan's update action lacks +// action.Current. ComputePlan upstream should never emit such a plan, +// but doUpdate must not panic — it should call Update with an empty +// ProviderID (matching the skeleton's behavior) so the driver can +// surface its own typed validation error. +func TestApplyPlan_Update_NilCurrentIsHandledDefensively(t *testing.T) { + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "update", Resource: spec("orphan", "infra.vpc"), Current: nil}, + }, + } + fp := newCaptureFakeProvider() + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if got := fp.driver.updateRef.ProviderID; got != "" { + t.Errorf("nil Current must yield empty ProviderID; got %q", got) + } + // Per-action result behavior: driver returned non-nil success → + // no errors, output appended. The contract is that doUpdate + // doesn't synthesize a precondition error; the driver is the + // authority on what an empty ProviderID means. + if len(result.Errors) != 0 { + t.Errorf("unexpected per-action errors for nil Current: %v", result.Errors) + } +} + +// TestApplyPlan_Delete_InvokesDriverDelete is the latent-bug-fix test +// from the design notes: today's DOProvider.Apply has no +// `case "delete":` arm, so wfctl's state-prune action silently skips +// cloud-resource deletion. doDelete CLOSES that gap by always calling +// driver.Delete. This test fails on the design's pre-T3.3 codepath. +func TestApplyPlan_Delete_InvokesDriverDelete(t *testing.T) { + const knownID = "do-vpc-xyz789" + cur := &interfaces.ResourceState{ + Name: "old-vpc", + Type: "infra.vpc", + ProviderID: knownID, + } + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "delete", Resource: spec("old-vpc", "infra.vpc"), Current: cur}, + }, + } + fp := newCaptureFakeProvider() + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.Errors) != 0 { + t.Fatalf("unexpected errors: %v", result.Errors) + } + if fp.driver.deleteCount != 1 { + t.Errorf("driver.Delete must be called exactly once for delete action; got %d", fp.driver.deleteCount) + } + if got := fp.driver.deleteRef.ProviderID; got != knownID { + t.Errorf("Delete ResourceRef.ProviderID: got %q want %q", got, knownID) + } +} + +// TestApplyPlan_Delete_DriverErrorRecorded verifies that a driver-level +// delete failure is recorded in result.Errors with the action + +// resource captured, matching the per-action error contract used by +// the rest of the dispatch loop. +func TestApplyPlan_Delete_DriverErrorRecorded(t *testing.T) { + deleteErr := errors.New("delete failed: 503 service unavailable") + cur := &interfaces.ResourceState{Name: "old-vpc", Type: "infra.vpc", ProviderID: "do-id"} + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "delete", Resource: spec("old-vpc", "infra.vpc"), Current: cur}, + }, + } + fp := newCaptureFakeProvider() + fp.driver.deleteErr = deleteErr + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.Errors) != 1 { + t.Fatalf("expected 1 per-action error; got %d (%v)", len(result.Errors), result.Errors) + } + if result.Errors[0].Action != "delete" { + t.Errorf("Action: got %q want %q", result.Errors[0].Action, "delete") + } + if result.Errors[0].Resource != "old-vpc" { + t.Errorf("Resource: got %q want %q", result.Errors[0].Resource, "old-vpc") + } + if got := result.Errors[0].Error; got == "" || got != deleteErr.Error() { + t.Errorf("Error: got %q want bare driver error %q", got, deleteErr.Error()) + } +} + +// TestApplyPlan_Update_DriverErrorRecorded mirrors the Delete error +// recording test — driver Update failures must surface in +// result.Errors with the canonical fields populated. +func TestApplyPlan_Update_DriverErrorRecorded(t *testing.T) { + updateErr := errors.New("update failed: 422 invalid spec") + cur := &interfaces.ResourceState{Name: "vpc-1", Type: "infra.vpc", ProviderID: "do-id"} + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "update", Resource: spec("vpc-1", "infra.vpc"), Current: cur}, + }, + } + fp := newCaptureFakeProvider() + fp.driver.updateErr = updateErr + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if len(result.Errors) != 1 { + t.Fatalf("expected 1 per-action error; got %d (%v)", len(result.Errors), result.Errors) + } + if result.Errors[0].Action != "update" { + t.Errorf("Action: got %q want %q", result.Errors[0].Action, "update") + } + if got := result.Errors[0].Error; got != updateErr.Error() { + t.Errorf("Error: got %q want bare driver error %q", got, updateErr.Error()) + } +} From b17d70392a61c6d834210a0a89f2007649a09e4a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:18:43 -0400 Subject: [PATCH 29/38] feat(iac): doReplace populates ApplyResult.ReplaceIDMap --- iac/wfctlhelpers/apply.go | 36 +++- iac/wfctlhelpers/apply_replace_test.go | 244 +++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 iac/wfctlhelpers/apply_replace_test.go diff --git a/iac/wfctlhelpers/apply.go b/iac/wfctlhelpers/apply.go index e3850320d..6a93979de 100644 --- a/iac/wfctlhelpers/apply.go +++ b/iac/wfctlhelpers/apply.go @@ -235,9 +235,30 @@ func doUpdate(ctx context.Context, d interfaces.ResourceDriver, action interface return nil } -// doReplace is the T3.1 skeleton — Delete + Create. T3.4 fills in the -// ReplaceIDMap propagation so dependent resources can pick up the new -// ProviderID via JIT substitution in W-5. +// doReplace decomposes a Replace action into Delete-then-Create on the +// driver and propagates the new ProviderID through +// result.ReplaceIDMap[action.Resource.Name] so JIT substitution in W-5 +// can patch dependent resources whose configs reference the replaced +// resource by name. +// +// Failure semantics: +// - Delete fails → return wrapped "replace: delete: "; Create +// does NOT run; ReplaceIDMap is NOT populated for this resource. +// - Delete succeeds, Create fails → return wrapped +// "replace: create: "; ReplaceIDMap stays empty for this +// resource. Operators inspect the apply log + the empty-for-this- +// name slot in ReplaceIDMap to know which resources are in a +// half-replaced state and need manual cloud restoration. +// - Both succeed → result.Resources gets the new output appended, +// result.ReplaceIDMap[action.Resource.Name] = new ProviderID. Map +// is lazily-initialized on first successful Replace so plans with +// no Replace actions don't carry an empty map through serialisation. +// +// The "replace: ..." prefix is essential because Replace decomposes +// into two driver calls — without it, an operator reading +// result.Errors couldn't tell whether the Delete or the Create failed. +// Other sub-functions (doCreate non-recovery path, doUpdate, doDelete) +// pass driver errors through unchanged. func doReplace(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult) error { if err := d.Delete(ctx, refFromAction(action)); err != nil { return fmt.Errorf("replace: delete: %w", err) @@ -248,6 +269,15 @@ func doReplace(ctx context.Context, d interfaces.ResourceDriver, action interfac } if out != nil { result.Resources = append(result.Resources, *out) + // Lazy-init: only allocate the map when there's an actual + // entry to record. ApplyResult.ReplaceIDMap stays nil for + // plans with no Replace actions, which the omitempty JSON tag + // then drops from the encoded form (covered by + // TestApplyResult_OmitEmptyContract in interfaces/iac_state_test.go). + if result.ReplaceIDMap == nil { + result.ReplaceIDMap = make(map[string]string) + } + result.ReplaceIDMap[action.Resource.Name] = out.ProviderID } return nil } diff --git a/iac/wfctlhelpers/apply_replace_test.go b/iac/wfctlhelpers/apply_replace_test.go new file mode 100644 index 000000000..8ab71073e --- /dev/null +++ b/iac/wfctlhelpers/apply_replace_test.go @@ -0,0 +1,244 @@ +package wfctlhelpers + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// orderRecordingDriver records the order of Delete vs. Create +// invocations so doReplace's "delete THEN create" contract can be +// asserted exactly. createReturn is the canned Create output (carries +// the NEW ProviderID); deleteFn / createFn are optional hooks that +// override the default success behavior. +type orderRecordingDriver struct { + *fakeDriver + deleteAt int // sequence number when Delete was called (0 if not called) + createAt int // sequence number when Create was called (0 if not called) + step int + createReturn *interfaces.ResourceOutput + deleteErr error + createErr error +} + +func (d *orderRecordingDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { + d.fakeDriver.deleteCount++ + d.step++ + d.deleteAt = d.step + return d.deleteErr +} + +func (d *orderRecordingDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.fakeDriver.createCount++ + d.step++ + d.createAt = d.step + if d.createErr != nil { + return nil, d.createErr + } + if d.createReturn != nil { + return d.createReturn, nil + } + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: "fake-id-" + spec.Name}, nil +} + +// orderRecordingProvider returns the orderRecordingDriver for any +// resource type. +type orderRecordingProvider struct { + *fakeProvider + driver *orderRecordingDriver +} + +func newOrderRecordingProvider() *orderRecordingProvider { + base := newFakeProvider() + return &orderRecordingProvider{ + fakeProvider: base, + driver: &orderRecordingDriver{fakeDriver: base.driver}, + } +} + +func (p *orderRecordingProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return p.driver, nil +} + +// stateWithID is a helper that builds a ResourceState with a known +// ProviderID, used to seed the "old" side of a Replace action. +func stateWithID(name, providerID string) *interfaces.ResourceState { + return &interfaces.ResourceState{Name: name, ProviderID: providerID} +} + +// TestApplyPlan_Replace_DeletesThenCreates_PropagatesNewID is the +// canonical T3.4 test: Replace must (1) call Delete first, (2) call +// Create after the Delete, (3) thread the NEW ProviderID from Create +// into result.Resources. +func TestApplyPlan_Replace_DeletesThenCreates_PropagatesNewID(t *testing.T) { + fp := newOrderRecordingProvider() + fp.driver.createReturn = &interfaces.ResourceOutput{ + Name: "vpc", Type: "infra.vpc", ProviderID: "new-uuid", + } + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "replace", Resource: spec("vpc", "infra.vpc"), Current: stateWithID("vpc", "old-uuid")}, + }} + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if fp.driver.deleteAt == 0 || fp.driver.createAt == 0 { + t.Errorf("Replace should call both Delete and Create; deleteAt=%d createAt=%d", + fp.driver.deleteAt, fp.driver.createAt) + } + if fp.driver.createAt < fp.driver.deleteAt { + t.Errorf("Create must run AFTER Delete; deleteAt=%d createAt=%d", + fp.driver.deleteAt, fp.driver.createAt) + } + if len(result.Resources) != 1 || result.Resources[0].ProviderID != "new-uuid" { + t.Errorf("expected new ProviderID in result.Resources, got %+v", result.Resources) + } +} + +// TestApplyPlan_Replace_PopulatesReplaceIDMap is the new-in-T3.4 +// contract: result.ReplaceIDMap[action.Resource.Name] must equal the +// new ProviderID returned by Create. Keyed by the *replaced* resource's +// Name (per T3.0.4 godoc — fixed during T3.0.4 review). Lazy-init on +// first Replace. +func TestApplyPlan_Replace_PopulatesReplaceIDMap(t *testing.T) { + fp := newOrderRecordingProvider() + fp.driver.createReturn = &interfaces.ResourceOutput{ + Name: "vpc", Type: "infra.vpc", ProviderID: "new-uuid", + } + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "replace", Resource: spec("vpc", "infra.vpc"), Current: stateWithID("vpc", "old-uuid")}, + }} + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if got := result.ReplaceIDMap["vpc"]; got != "new-uuid" { + t.Errorf("ReplaceIDMap[vpc]: got %q want %q (full map: %+v)", got, "new-uuid", result.ReplaceIDMap) + } +} + +// TestApplyPlan_Replace_MultipleActionsAllPopulate verifies the map +// accumulates across actions: two Replace actions in one plan must +// produce two entries in result.ReplaceIDMap, each keyed by its +// replaced resource's name. +func TestApplyPlan_Replace_MultipleActionsAllPopulate(t *testing.T) { + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "replace", Resource: spec("vpc", "infra.vpc"), Current: stateWithID("vpc", "old-vpc")}, + {Action: "replace", Resource: spec("db", "infra.database"), Current: stateWithID("db", "old-db")}, + }} + // Use a per-call provider that returns a fresh ID per resource so + // the map entries are distinguishable. + fp := &perResourceReplaceProvider{ + fakeProvider: newFakeProvider(), + newIDs: map[string]string{"vpc": "new-vpc-id", "db": "new-db-id"}, + } + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if got := result.ReplaceIDMap["vpc"]; got != "new-vpc-id" { + t.Errorf("ReplaceIDMap[vpc]: got %q want %q", got, "new-vpc-id") + } + if got := result.ReplaceIDMap["db"]; got != "new-db-id" { + t.Errorf("ReplaceIDMap[db]: got %q want %q", got, "new-db-id") + } +} + +// perResourceReplaceProvider mints a fresh new-ID per resource Name so +// MultipleActionsAllPopulate can distinguish the two map entries. Each +// driver is a one-shot recorder owned by the provider. +type perResourceReplaceProvider struct { + *fakeProvider + newIDs map[string]string + driver *perResourceReplaceDriver +} + +func (p *perResourceReplaceProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + if p.driver == nil { + p.driver = &perResourceReplaceDriver{fakeDriver: p.fakeProvider.driver, newIDs: p.newIDs} + } + return p.driver, nil +} + +type perResourceReplaceDriver struct { + *fakeDriver + newIDs map[string]string +} + +func (d *perResourceReplaceDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { + d.fakeDriver.deleteCount++ + return nil +} + +func (d *perResourceReplaceDriver) Create(_ context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.fakeDriver.createCount++ + id := d.newIDs[spec.Name] + if id == "" { + id = "fallback-id" + } + return &interfaces.ResourceOutput{Name: spec.Name, Type: spec.Type, ProviderID: id}, nil +} + +// TestApplyPlan_Replace_DeleteFailsDoesNotCreate verifies that when +// the Delete sub-step of a Replace fails, Create is NOT called and +// result.ReplaceIDMap is NOT populated for that resource. The +// per-action error must surface with the canonical "replace: delete:" +// prefix that doReplace decorates (not bare driver error — Replace +// decomposes, so the prefix tells the operator which sub-step failed). +func TestApplyPlan_Replace_DeleteFailsDoesNotCreate(t *testing.T) { + fp := newOrderRecordingProvider() + fp.driver.deleteErr = errors.New("delete failed: 503") + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "replace", Resource: spec("vpc", "infra.vpc"), Current: stateWithID("vpc", "old-uuid")}, + }} + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if fp.driver.createAt != 0 { + t.Errorf("Create must not run when Delete fails; createAt=%d", fp.driver.createAt) + } + if _, present := result.ReplaceIDMap["vpc"]; present { + t.Errorf("ReplaceIDMap must not contain vpc when Delete failed; got %+v", result.ReplaceIDMap) + } + if len(result.Errors) != 1 { + t.Fatalf("expected 1 per-action error; got %d (%v)", len(result.Errors), result.Errors) + } + if !strings.HasPrefix(result.Errors[0].Error, "replace: delete:") { + t.Errorf("expected canonical 'replace: delete:' prefix; got %q", result.Errors[0].Error) + } +} + +// TestApplyPlan_Replace_CreateFailsLeavesMapEmpty verifies the +// post-Delete pre-Create failure window: Delete succeeded but Create +// failed → ReplaceIDMap stays empty for this resource (no spurious +// new-ID entry). The plan rollback note says operators inspect this +// state checkpoint to know which resources are in a half-replaced +// state; "absent from ReplaceIDMap + Resource was Replace target" is +// the canonical signal. +func TestApplyPlan_Replace_CreateFailsLeavesMapEmpty(t *testing.T) { + fp := newOrderRecordingProvider() + fp.driver.createErr = errors.New("create failed: 422") + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "replace", Resource: spec("vpc", "infra.vpc"), Current: stateWithID("vpc", "old-uuid")}, + }} + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if fp.driver.deleteAt == 0 { + t.Errorf("Delete should still have run (failure was in Create, not Delete)") + } + if _, present := result.ReplaceIDMap["vpc"]; present { + t.Errorf("ReplaceIDMap must not contain vpc when Create failed; got %+v", result.ReplaceIDMap) + } + if len(result.Errors) != 1 { + t.Fatalf("expected 1 per-action error; got %d (%v)", len(result.Errors), result.Errors) + } + if !strings.HasPrefix(result.Errors[0].Error, "replace: create:") { + t.Errorf("expected canonical 'replace: create:' prefix; got %q", result.Errors[0].Error) + } +} From 8774205d291cb426f4922f5fdb64a1a9d155b58d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:24:01 -0400 Subject: [PATCH 30/38] feat(iac): add diff cache with LRU eviction + corruption recovery --- iac/diffcache/cache.go | 185 +++++++++++++++++++++++++ iac/diffcache/cache_corruption_test.go | 107 ++++++++++++++ iac/diffcache/cache_eviction_test.go | 122 ++++++++++++++++ iac/diffcache/cache_filesystem.go | 162 ++++++++++++++++++++++ iac/diffcache/cache_inmemory.go | 44 ++++++ iac/diffcache/cache_test.go | 120 ++++++++++++++++ 6 files changed, 740 insertions(+) create mode 100644 iac/diffcache/cache.go create mode 100644 iac/diffcache/cache_corruption_test.go create mode 100644 iac/diffcache/cache_eviction_test.go create mode 100644 iac/diffcache/cache_filesystem.go create mode 100644 iac/diffcache/cache_inmemory.go create mode 100644 iac/diffcache/cache_test.go diff --git a/iac/diffcache/cache.go b/iac/diffcache/cache.go new file mode 100644 index 000000000..7d164548a --- /dev/null +++ b/iac/diffcache/cache.go @@ -0,0 +1,185 @@ +// Package diffcache caches per-resource Diff results so a wfctl invocation +// that re-runs a Plan against unchanged inputs can skip the (sometimes +// network-expensive) provider-side Diff call. The cache is purely an +// amortization optimization, NOT a correctness mechanism — apply paths +// remain correct on a 100% miss rate (which is exactly what CI sees on +// every fresh runner). +// +// # Storage backends +// +// Cache selection is driven by the WFCTL_DIFFCACHE env var, resolved by +// [New]: +// +// - `disabled` → noop cache (every Get misses; Put is a no-op). Use +// this when an operator wants fully-deterministic Plan/Apply timing +// with no shared state across invocations. +// - `:memory:` → in-memory cache that lives only for the current +// process. CI workflows in this repo set WFCTL_DIFFCACHE=:memory: +// explicitly so containerized runners never write to disk. +// - any other value (or unset) → filesystem cache rooted at +// `~/.cache/wfctl/diff/`. +// +// # CI ephemerality (load-bearing) +// +// CI runners are ephemeral — each job starts with an empty cache. +// Workflow correctness MUST NOT depend on a cache hit. The diff cache +// is an operator-local performance optimization for repeated +// `wfctl infra plan` invocations against the same checkout. +// +// # Cache key +// +// The cache key is a [Key] tuple of (PluginVersion, Type, ProviderID, +// SHAConfig, SHAOutputs). Plugin downgrades naturally invalidate +// entries since PluginVersion is part of the key — old entries persist +// on disk until the LRU eviction reclaims them; the size cap (1024 +// entries / 64 MiB) bounds the disk waste. +// +// # Schema versioning +// +// Each cache file embeds [cacheSchemaVersion] in a JSON envelope. On +// Get, a mismatched version is treated identically to file corruption: +// the entry is silently evicted and the caller re-Diffs. +// +// # T3.5 / W-3a status +// +// This package ships in W-3a. The consumer that wires it into +// platform.ComputePlan lands in W-3b/T3.6f. Until then, the cache is +// callable but never invoked from production code paths. +package diffcache + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// cacheSchemaVersion is the on-disk envelope version. Bump on any +// breaking change to the JSON shape; old files will be silently +// evicted on the next Get (same code path as corruption recovery). +const cacheSchemaVersion = 1 + +// defaultMaxEntries is the LRU eviction trigger by count. +const defaultMaxEntries = 1024 + +// defaultMaxBytes is the LRU eviction trigger by total on-disk size +// (64 MiB). Whichever cap (entries or bytes) is exceeded first triggers +// the eviction pass. +const defaultMaxBytes = 64 * 1024 * 1024 + +// evictionFraction is the fraction of cache entries to evict in one +// over-cap pass. 0.10 = oldest 10%; matches rev2 lifecycle constraint. +const evictionFraction = 0.10 + +// Key tuples the inputs to a single Diff. Two Keys are equal iff every +// field matches; the canonical sha256 fingerprint of the key +// determines the on-disk filename. +type Key struct { + // PluginVersion is the plugin's name@version string, e.g., + // "do@v0.10.0". Plugin downgrades naturally invalidate cache + // entries via this field. + PluginVersion string `json:"plugin_version"` + // Type is the canonical resource type, e.g., "infra.vpc". + Type string `json:"type"` + // ProviderID is the resource's cloud-side identifier; empty for + // net-new resources. + ProviderID string `json:"provider_id"` + // SHAConfig is the sha256-hex of canonical-marshal(spec.Config). + SHAConfig string `json:"sha_config"` + // SHAOutputs is the sha256-hex of canonical-marshal(currentState.Outputs); + // empty for net-new resources. + SHAOutputs string `json:"sha_outputs"` +} + +// Cache is the diff-result cache. Implementations are safe for +// concurrent use (the backing fs cache uses os-level atomic file ops; +// the in-memory cache locks internally). +type Cache interface { + // Get returns the cached DiffResult for key. The boolean is true + // iff the entry was found and successfully decoded; corruption, + // schema-version mismatch, and missing entries all yield + // (zero-value, false). + Get(key Key) (interfaces.DiffResult, bool) + // Put stores result under key. Errors during Put (e.g., disk + // full, serialization failure) are silently swallowed because + // cache misses are correct — apply behavior must not depend on + // Put success. + Put(key Key, result interfaces.DiffResult) +} + +// New returns a Cache configured by the WFCTL_DIFFCACHE env var. +// +// - "disabled" → [NewNoop] +// - ":memory:" → [NewMemory] +// - default → [NewFilesystem] rooted at the user cache directory +// (typically `~/.cache/wfctl/diff/`). +// +// When the user cache directory cannot be resolved, falls back to the +// in-memory cache so the caller still gets a working Cache (the +// filesystem path being unavailable is the operator's hint that disk +// caching is off). +func New() Cache { + switch strings.TrimSpace(os.Getenv("WFCTL_DIFFCACHE")) { + case "disabled": + return NewNoop() + case ":memory:": + return NewMemory() + } + dir, err := userCacheDir() + if err != nil { + return NewMemory() + } + return NewFilesystem(dir) +} + +// NewNoop returns a Cache that always misses. Put is a no-op. +func NewNoop() Cache { return &noopCache{} } + +// noopCache is the disabled-cache implementation. +type noopCache struct{} + +func (noopCache) Get(_ Key) (interfaces.DiffResult, bool) { return interfaces.DiffResult{}, false } +func (noopCache) Put(_ Key, _ interfaces.DiffResult) { /* no-op */ } + +// userCacheDir returns the diff-cache root under the user cache +// directory, e.g., `~/.cache/wfctl/diff/` on Linux. Honors XDG_CACHE_HOME. +func userCacheDir() (string, error) { + base, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("resolve user cache dir: %w", err) + } + return filepath.Join(base, "wfctl", "diff"), nil +} + +// keyFingerprint returns the sha256-hex fingerprint of the canonical +// key serialization. Exported as a package-level function so tests can +// assert key-stability without coupling to the filesystem cache. +func keyFingerprint(k Key) string { + // Concatenate fields with a NUL separator so per-field collisions + // are impossible (any field containing NUL would have to be + // deliberately injected; cloud IDs and resource types don't). + var b strings.Builder + b.WriteString(k.PluginVersion) + b.WriteByte(0) + b.WriteString(k.Type) + b.WriteByte(0) + b.WriteString(k.ProviderID) + b.WriteByte(0) + b.WriteString(k.SHAConfig) + b.WriteByte(0) + b.WriteString(k.SHAOutputs) + sum := sha256.Sum256([]byte(b.String())) + return hex.EncodeToString(sum[:]) +} + +// envelope is the JSON shape persisted to disk and used by the +// in-memory cache to validate on Get. The schemaVersion field gates +// silent eviction on a future schema bump. +type envelope struct { + SchemaVersion int `json:"schemaVersion"` + Result interfaces.DiffResult `json:"result"` +} diff --git a/iac/diffcache/cache_corruption_test.go b/iac/diffcache/cache_corruption_test.go new file mode 100644 index 000000000..98009aa75 --- /dev/null +++ b/iac/diffcache/cache_corruption_test.go @@ -0,0 +1,107 @@ +package diffcache + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestCache_CorruptionRecovery_TruncatedFile verifies that a partially- +// written or truncated cache file does not crash Get and is silently +// removed so the next Put succeeds. The contract: Get returns +// (_, false) on parse failure; the file is deleted from disk. +func TestCache_CorruptionRecovery_TruncatedFile(t *testing.T) { + dir := t.TempDir() + c := NewFilesystem(dir).(*filesystemCache) + key := Key{Type: "infra.vpc", ProviderID: "vpc-abc"} + c.Put(key, interfaces.DiffResult{NeedsUpdate: true}) + + // Truncate the cache file to simulate a partial write or crash mid-Put. + path := c.pathFor(key) + if err := os.WriteFile(path, []byte("{not val"), 0o644); err != nil { + t.Fatalf("failed to truncate cache file: %v", err) + } + + if _, hit := c.Get(key); hit { + t.Errorf("expected miss on truncated file; got hit") + } + // The corrupt file should be removed so a fresh Put can succeed. + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("corrupt cache file should be deleted; stat err=%v", err) + } + + // Re-Put + Get round-trip must work after corruption recovery. + c.Put(key, interfaces.DiffResult{NeedsReplace: true}) + got, hit := c.Get(key) + if !hit { + t.Fatal("expected hit after re-Put following corruption") + } + if !got.NeedsReplace { + t.Errorf("post-recovery Get returned wrong value: %+v", got) + } +} + +// TestCache_CorruptionRecovery_SchemaVersionMismatch verifies that +// cache files written under a different schema version are silently +// evicted on Get — same code path as JSON corruption since we treat +// both as "cache file we cannot use." Lock the contract so a future +// schema bump (cacheSchemaVersion = 2) keeps the silent-eviction +// behavior. +func TestCache_CorruptionRecovery_SchemaVersionMismatch(t *testing.T) { + dir := t.TempDir() + c := NewFilesystem(dir).(*filesystemCache) + key := Key{Type: "infra.vpc", ProviderID: "vpc-abc"} + + // Write a valid-JSON file with a schemaVersion that doesn't match + // the current cacheSchemaVersion constant. Use a value >> current + // so it can't accidentally match a future bump. + path := c.pathFor(key) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + envelope := map[string]any{ + "schemaVersion": 9999, + "result": map[string]any{"needs_update": true}, + } + data, _ := json.Marshal(envelope) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + + if _, hit := c.Get(key); hit { + t.Errorf("expected miss on schema-version mismatch; got hit") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("schema-mismatched cache file should be deleted; stat err=%v", err) + } +} + +// TestCache_CorruptionRecovery_GetReturnsNoErrorOnParseFailure proves +// the no-error contract: callers Get a (zero, false) tuple, never a +// surfaced error. Cache failures must NEVER abort apply. +func TestCache_CorruptionRecovery_GetReturnsNoErrorOnParseFailure(t *testing.T) { + dir := t.TempDir() + c := NewFilesystem(dir).(*filesystemCache) + key := Key{Type: "x"} + path := c.pathFor(key) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("\x00\x00not-json\x00\x00"), 0o644); err != nil { + t.Fatal(err) + } + // We can't directly assert "no error returned" via the Get + // signature, but we can lock that hit is false and the file is + // removed (the canonical recovery path). A panic would also fail + // this test, which is the implicit contract this case anchors. + got, hit := c.Get(key) + if hit { + t.Error("expected miss on garbage bytes") + } + if got.NeedsUpdate || got.NeedsReplace || len(got.Changes) > 0 { + t.Errorf("zero-value DiffResult on miss; got %+v", got) + } +} diff --git a/iac/diffcache/cache_eviction_test.go b/iac/diffcache/cache_eviction_test.go new file mode 100644 index 000000000..cb025154c --- /dev/null +++ b/iac/diffcache/cache_eviction_test.go @@ -0,0 +1,122 @@ +package diffcache + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestCache_LRUEvictionByCount verifies the LRU eviction trigger when +// the entry count exceeds maxEntries. Per the rev2 lifecycle constraint +// (10% per-overflow batch), the eviction is amortized — for the test +// we drive it with a small cap (10 entries; 10% = 1 evicted per +// over-cap Put) to keep the test fast. +func TestCache_LRUEvictionByCount(t *testing.T) { + dir := t.TempDir() + c := &filesystemCache{ + dir: dir, + maxEntries: 10, + maxBytes: defaultMaxBytes, // not the trigger here + } + // Fill to the cap. Each Put with a different key creates a new file. + // We sleep 1ms between Puts so mtimes are distinguishable; the OS + // mtime resolution is 1ns on most filesystems but coarse on some, + // so 1ms is a safe lower bound. + for i := range 10 { + c.Put(Key{Type: fmt.Sprintf("k%d", i)}, interfaces.DiffResult{}) + time.Sleep(time.Millisecond) + } + if got := countCacheFiles(t, dir); got != 10 { + t.Fatalf("expected 10 entries pre-overflow; got %d", got) + } + // Trigger overflow: one more Put → cap exceeded → evict 1 (10% of 10). + c.Put(Key{Type: "k_overflow"}, interfaces.DiffResult{}) + got := countCacheFiles(t, dir) + // After eviction (10% of 10 = 1) + new entry: 10 - 1 + 1 = 10. + if got != 10 { + t.Errorf("post-overflow count: got %d entries, want 10 (cap holds)", got) + } + // Oldest key (k0) should have been evicted. + if _, hit := c.Get(Key{Type: "k0"}); hit { + t.Errorf("oldest key (k0) should be evicted") + } + // Newest key (k_overflow) should still be present. + if _, hit := c.Get(Key{Type: "k_overflow"}); !hit { + t.Errorf("newest key (k_overflow) should remain") + } +} + +// TestCache_LRUEvictionBatchOf10Percent verifies that when the cap is +// large enough to make 10% a multi-entry batch, multiple oldest +// entries are evicted in one pass. This locks the "amortized cost" / +// "evict oldest 10% in one pass" rev2 constraint. +func TestCache_LRUEvictionBatchOf10Percent(t *testing.T) { + dir := t.TempDir() + c := &filesystemCache{ + dir: dir, + maxEntries: 100, + maxBytes: defaultMaxBytes, + } + for i := range 100 { + c.Put(Key{Type: fmt.Sprintf("k%03d", i)}, interfaces.DiffResult{}) + time.Sleep(time.Millisecond) + } + // Trigger overflow: one extra Put → evict 10 oldest (10% of 100) + + // add 1 → 100 - 10 + 1 = 91. + c.Put(Key{Type: "k_overflow"}, interfaces.DiffResult{}) + got := countCacheFiles(t, dir) + if got < 88 || got > 92 { + // Allow ±2 for filesystems with mtime resolution coarser than + // the 1ms sleep; the central tendency must be ~91. + t.Errorf("post-overflow count: got %d, want ~91 (allow ±2 for mtime precision)", got) + } + // First 5 oldest keys should be evicted. + for i := range 5 { + k := Key{Type: fmt.Sprintf("k%03d", i)} + if _, hit := c.Get(k); hit { + t.Errorf("k%03d should be in the evicted oldest 10%%", i) + } + } +} + +// countCacheFiles returns the number of *.json files under dir. Used +// to assert eviction behavior without depending on cache internals. +func countCacheFiles(t *testing.T, dir string) int { + t.Helper() + matches, err := filepath.Glob(filepath.Join(dir, "*.json")) + if err != nil { + t.Fatalf("glob: %v", err) + } + return len(matches) +} + +// TestCache_EvictionTouchesNothingWhenUnderCap verifies the no-op +// case: Put when count < maxEntries leaves all existing files alone. +func TestCache_EvictionTouchesNothingWhenUnderCap(t *testing.T) { + dir := t.TempDir() + c := &filesystemCache{ + dir: dir, + maxEntries: 100, + maxBytes: defaultMaxBytes, + } + c.Put(Key{Type: "a"}, interfaces.DiffResult{}) + c.Put(Key{Type: "b"}, interfaces.DiffResult{}) + c.Put(Key{Type: "c"}, interfaces.DiffResult{}) + if got := countCacheFiles(t, dir); got != 3 { + t.Errorf("under-cap puts should not evict; got %d entries", got) + } + // All three should be retrievable. + for _, k := range []string{"a", "b", "c"} { + if _, hit := c.Get(Key{Type: k}); !hit { + t.Errorf("under-cap key %q should be retained", k) + } + } + // Sanity: verify no errant background eviction took place. + if _, err := os.Stat(filepath.Join(dir, "*.json")); err == nil { + t.Error("glob path should not exist as a literal file (sanity check)") + } +} diff --git a/iac/diffcache/cache_filesystem.go b/iac/diffcache/cache_filesystem.go new file mode 100644 index 000000000..c822e336b --- /dev/null +++ b/iac/diffcache/cache_filesystem.go @@ -0,0 +1,162 @@ +package diffcache + +import ( + "encoding/json" + "errors" + "log" + "os" + "path/filepath" + "sort" + "sync" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// filesystemCache is the file-backed Cache implementation. Cache files +// live under cache.dir, named by the sha256 fingerprint of the Key +// with a .json extension, e.g., +// `/abcdef0123456789....json`. The schema-version envelope +// gates silent eviction on a future schema bump. +// +// Concurrency: Get is serialized through the read filesystem; Put +// uses an os.WriteFile that is atomic on POSIX-compliant filesystems +// for sizes <= PIPE_BUF, and for larger writes the worst case is a +// truncated file that the next Get treats as corruption (silent +// eviction). The eviction-on-overflow scan is guarded by evictMu so +// concurrent Puts don't multiply-evict. +// +// Logging: a single info-level log fires the first time corruption is +// observed in this process so an operator has a breadcrumb without +// log spam. +type filesystemCache struct { + dir string + maxEntries int + maxBytes int64 + evictMu sync.Mutex + corruptOnce sync.Once +} + +// NewFilesystem returns a Cache whose entries are persisted under dir. +// The directory is created on first Put if absent. Callers may pass +// any directory; the [New] factory uses `~/.cache/wfctl/diff/`. +func NewFilesystem(dir string) Cache { + return &filesystemCache{ + dir: dir, + maxEntries: defaultMaxEntries, + maxBytes: defaultMaxBytes, + } +} + +// pathFor returns the on-disk path for key. Exported (within-package) +// so tests can mutate the file directly to exercise the corruption +// recovery path. +func (c *filesystemCache) pathFor(k Key) string { + return filepath.Join(c.dir, keyFingerprint(k)+".json") +} + +func (c *filesystemCache) Get(k Key) (interfaces.DiffResult, bool) { + path := c.pathFor(k) + data, err := os.ReadFile(path) + if err != nil { + // Either file-not-found (cache miss, normal) or some other read + // error (permissions, transient I/O). Both are treated as miss + // — apply correctness must not hinge on Get success. + return interfaces.DiffResult{}, false + } + var env envelope + if err := json.Unmarshal(data, &env); err != nil { + c.handleCorruption(path, err) + return interfaces.DiffResult{}, false + } + if env.SchemaVersion != cacheSchemaVersion { + c.handleCorruption(path, errors.New("schema-version mismatch")) + return interfaces.DiffResult{}, false + } + return env.Result, true +} + +func (c *filesystemCache) Put(k Key, result interfaces.DiffResult) { + if err := os.MkdirAll(c.dir, 0o755); err != nil { + // Disk-side errors during Put are intentionally silent: the + // next Get will miss (correct), and the operator already has + // "stuff isn't working" signal from elsewhere. + return + } + env := envelope{SchemaVersion: cacheSchemaVersion, Result: result} + data, err := json.Marshal(env) + if err != nil { + return + } + path := c.pathFor(k) + _ = os.WriteFile(path, data, 0o644) + c.maybeEvict() +} + +// handleCorruption silently deletes the corrupt file and emits a +// once-per-process info log. The single log gives operators a +// breadcrumb without spamming on every Get when an attacker or a +// rogue tool has filled the cache directory with garbage files. +func (c *filesystemCache) handleCorruption(path string, cause error) { + _ = os.Remove(path) + c.corruptOnce.Do(func() { + log.Printf("info: diffcache: detected corrupted cache file %q (%v); silently re-Diffing. This message logs once per process.", path, cause) + }) +} + +// maybeEvict scans the cache directory and, if either cap is exceeded, +// evicts the oldest evictionFraction (10%) of entries by mtime. The +// scan + sort cost is amortized: a single Put after N entries +// triggers one full scan; the next eviction-fraction Puts after that +// see no scan because the cap is back below threshold. +func (c *filesystemCache) maybeEvict() { + c.evictMu.Lock() + defer c.evictMu.Unlock() + entries, err := os.ReadDir(c.dir) + if err != nil { + return + } + // Filter to *.json files (defensive in case of stray files). + type fileInfo struct { + path string + mtime int64 + size int64 + } + files := make([]fileInfo, 0, len(entries)) + var totalBytes int64 + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" { + continue + } + info, err := e.Info() + if err != nil { + continue + } + files = append(files, fileInfo{ + path: filepath.Join(c.dir, e.Name()), + mtime: info.ModTime().UnixNano(), + size: info.Size(), + }) + totalBytes += info.Size() + } + overCount := len(files) > c.maxEntries + overBytes := totalBytes > c.maxBytes + if !overCount && !overBytes { + return + } + // Sort oldest-first by mtime. Files written in close succession + // may share an mtime under some filesystems; the secondary sort + // by path makes the order deterministic. + sort.Slice(files, func(i, j int) bool { + if files[i].mtime != files[j].mtime { + return files[i].mtime < files[j].mtime + } + return files[i].path < files[j].path + }) + // Evict the oldest fraction in one pass. Always at least 1 so + // tiny caps still make progress. + evictCount := max(int(float64(len(files))*evictionFraction), 1) + evictCount = min(evictCount, len(files)) + for i := range evictCount { + _ = os.Remove(files[i].path) + } +} diff --git a/iac/diffcache/cache_inmemory.go b/iac/diffcache/cache_inmemory.go new file mode 100644 index 000000000..671b20895 --- /dev/null +++ b/iac/diffcache/cache_inmemory.go @@ -0,0 +1,44 @@ +package diffcache + +import ( + "sync" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// memoryCache is the in-memory Cache implementation. Entries live for +// the current process; CI workflows in this repo set +// WFCTL_DIFFCACHE=:memory: explicitly so containerized runners never +// write cache data to disk. +// +// Concurrency: a single mutex guards the map. Diff results are small +// and operations are infrequent (one per resource per Plan), so a +// finer-grained scheme isn't justified. +type memoryCache struct { + mu sync.Mutex + entries map[string]interfaces.DiffResult +} + +// NewMemory returns an in-memory Cache. The returned Cache does not +// enforce a size cap — process lifetime is the eviction horizon. +// This matches the rev2 lifecycle constraint that the in-memory mode +// is the CI default and is not relied on for correctness. +func NewMemory() Cache { + return &memoryCache{entries: map[string]interfaces.DiffResult{}} +} + +func (c *memoryCache) Get(k Key) (interfaces.DiffResult, bool) { + c.mu.Lock() + defer c.mu.Unlock() + r, ok := c.entries[keyFingerprint(k)] + if !ok { + return interfaces.DiffResult{}, false + } + return r, true +} + +func (c *memoryCache) Put(k Key, result interfaces.DiffResult) { + c.mu.Lock() + defer c.mu.Unlock() + c.entries[keyFingerprint(k)] = result +} diff --git a/iac/diffcache/cache_test.go b/iac/diffcache/cache_test.go new file mode 100644 index 000000000..9aa038245 --- /dev/null +++ b/iac/diffcache/cache_test.go @@ -0,0 +1,120 @@ +package diffcache + +import ( + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestCache_FilesystemRoundtrip verifies the basic Get/Put roundtrip on +// a filesystem-backed cache: Put a DiffResult under a Key, then Get the +// same key returns the same DiffResult with hit=true. +func TestCache_FilesystemRoundtrip(t *testing.T) { + dir := t.TempDir() + c := NewFilesystem(dir) + key := Key{ + PluginVersion: "do@v0.10.0", + Type: "infra.vpc", + ProviderID: "vpc-abc", + SHAConfig: "deadbeef", + SHAOutputs: "cafebabe", + } + want := interfaces.DiffResult{ + NeedsUpdate: true, + Changes: []interfaces.FieldChange{ + {Path: "size", Old: "s-1vcpu-1gb", New: "s-2vcpu-2gb"}, + }, + } + c.Put(key, want) + got, hit := c.Get(key) + if !hit { + t.Fatal("expected cache hit after Put") + } + if got.NeedsUpdate != want.NeedsUpdate { + t.Errorf("NeedsUpdate: got %v want %v", got.NeedsUpdate, want.NeedsUpdate) + } + if len(got.Changes) != 1 || got.Changes[0].Path != "size" { + t.Errorf("Changes: got %+v want one entry with Path=size", got.Changes) + } +} + +// TestCache_FilesystemMissOnDifferentKey verifies key isolation: a Put +// under one key does not service a Get under any of the 5 fields' +// distinct values. Each field must contribute to the key independently. +func TestCache_FilesystemMissOnDifferentKey(t *testing.T) { + dir := t.TempDir() + c := NewFilesystem(dir) + base := Key{PluginVersion: "v1", Type: "T", ProviderID: "P", SHAConfig: "C", SHAOutputs: "O"} + c.Put(base, interfaces.DiffResult{NeedsUpdate: true}) + + cases := map[string]Key{ + "diff-pluginversion": {PluginVersion: "v2", Type: "T", ProviderID: "P", SHAConfig: "C", SHAOutputs: "O"}, + "diff-type": {PluginVersion: "v1", Type: "U", ProviderID: "P", SHAConfig: "C", SHAOutputs: "O"}, + "diff-providerid": {PluginVersion: "v1", Type: "T", ProviderID: "Q", SHAConfig: "C", SHAOutputs: "O"}, + "diff-shaconfig": {PluginVersion: "v1", Type: "T", ProviderID: "P", SHAConfig: "D", SHAOutputs: "O"}, + "diff-shaoutputs": {PluginVersion: "v1", Type: "T", ProviderID: "P", SHAConfig: "C", SHAOutputs: "X"}, + } + for name, k := range cases { + t.Run(name, func(t *testing.T) { + if _, hit := c.Get(k); hit { + t.Errorf("expected miss for distinct key field; got hit on %+v", k) + } + }) + } +} + +// TestCache_MemoryRoundtrip is the in-memory cache's basic test. +// Same contract as the filesystem cache. +func TestCache_MemoryRoundtrip(t *testing.T) { + c := NewMemory() + key := Key{Type: "infra.vpc", ProviderID: "vpc-abc"} + want := interfaces.DiffResult{NeedsReplace: true} + c.Put(key, want) + got, hit := c.Get(key) + if !hit { + t.Fatal("expected cache hit after Put") + } + if got.NeedsReplace != want.NeedsReplace { + t.Errorf("NeedsReplace: got %v want %v", got.NeedsReplace, want.NeedsReplace) + } +} + +// TestCache_NoopAlwaysMisses verifies the disabled cache: Put is a +// no-op, every Get returns hit=false. +func TestCache_NoopAlwaysMisses(t *testing.T) { + c := NewNoop() + key := Key{Type: "x"} + c.Put(key, interfaces.DiffResult{NeedsUpdate: true}) + if _, hit := c.Get(key); hit { + t.Error("noop cache should always miss") + } +} + +// TestCache_EnvDispatch verifies the New() factory's env-var driven +// dispatch: WFCTL_DIFFCACHE=disabled → noop; =:memory: → memory; +// default → filesystem (we just verify it's not noop/memory). +func TestCache_EnvDispatch(t *testing.T) { + t.Run("disabled", func(t *testing.T) { + t.Setenv("WFCTL_DIFFCACHE", "disabled") + c := New() + if _, ok := c.(*noopCache); !ok { + t.Errorf("WFCTL_DIFFCACHE=disabled should yield *noopCache; got %T", c) + } + }) + t.Run("memory", func(t *testing.T) { + t.Setenv("WFCTL_DIFFCACHE", ":memory:") + c := New() + if _, ok := c.(*memoryCache); !ok { + t.Errorf("WFCTL_DIFFCACHE=:memory: should yield *memoryCache; got %T", c) + } + }) + t.Run("default", func(t *testing.T) { + // Set HOME to a tempdir so we don't pollute the real cache dir. + t.Setenv("HOME", t.TempDir()) + t.Setenv("WFCTL_DIFFCACHE", "") + c := New() + if _, ok := c.(*filesystemCache); !ok { + t.Errorf("default should yield *filesystemCache; got %T", c) + } + }) +} From b735f62029279cdb2d52da1a289afdc1347051e5 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:28:12 -0400 Subject: [PATCH 31/38] =?UTF-8?q?fix(iac):=20T3.1.5/T3.2/T3.3=20review=20m?= =?UTF-8?q?inors=20=E2=80=94=20helper=20consistency,=20type-assertion=20co?= =?UTF-8?q?verage,=20prefix=20policy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent review-fix bundles: T3.1.5 (commit f5a7ce9 review — Minor 1): - apply_postcondition_test.go::fingerprint now delegates to inputsnapshot.Compute, mirroring cmd/wfctl/infra_apply_plan_test.go's fingerprintForTest. Drops the inline crypto/sha256 + encoding/hex imports. Future Compute-algorithm changes (prefix length, hash) now re-align both test files automatically — keeps the cross-package fixture parity guaranteed. T3.2 (commit 0c30eec review — Minors 1 + 2): - apply_create_test.go gains TestApplyPlan_Create_AlreadyExists_DriverDoesNotImplementUpsertSupporter + alreadyExistsBareDriver + bareDriverProvider. Covers the `!ok` arm of doCreate's `us, ok := d.(interfaces.UpsertSupporter)` type assertion — distinct code path from the existing ok-but-SupportsUpsert==false test. Compile-time premise check ensures the test stays meaningful if a future refactor lifts SupportsUpsert onto the embedded fakeDriver. - apply.go::doCreate godoc tightens the errors.Is contract to make the in-package vs at-the-ActionError-boundary distinction explicit. External callers reading [interfaces.ApplyResult].Errors lose errors.Is matching at the string-conversion boundary; the canonical "upsert: read after conflict:" prefix is the discriminant. Also documents the single-pass recovery contract (recovery Update that itself returns ErrResourceAlreadyExists surfaces unchanged rather than retriggering the recovery loop). T3.3 (commit a3fc98b review — Minors 1 + 2 + 4): - apply_update_delete_test.go::TestApplyPlan_Update_NilCurrentIsHandledDefensively now also asserts len(result.Resources) == 1 on the success path — locks the resource-append contract so a regression that skipped the append on nil Current would fail loudly. - apply_update_delete_test.go gains parallel TestApplyPlan_Delete_NilCurrentIsHandledDefensively. Same defensive shape: empty ProviderID flows to driver, no synthesized precondition error, deleteCount==1 (latent bug-fix from design — the v1 path silently skipped Delete; v2 must call it). - apply.go package godoc adds a "Per-action error-prefix policy" section documenting the decompose-then-prefix rule (bare on simple actions; "upsert: ..." / "replace: ..." on decomposing paths) so future reviewers don't suggest "let's add prefixes for consistency." --- iac/wfctlhelpers/apply.go | 48 +++++++++++++--- iac/wfctlhelpers/apply_create_test.go | 58 ++++++++++++++++++++ iac/wfctlhelpers/apply_postcondition_test.go | 19 ++++--- iac/wfctlhelpers/apply_update_delete_test.go | 39 +++++++++++++ 4 files changed, 149 insertions(+), 15 deletions(-) diff --git a/iac/wfctlhelpers/apply.go b/iac/wfctlhelpers/apply.go index 6a93979de..80b05321d 100644 --- a/iac/wfctlhelpers/apply.go +++ b/iac/wfctlhelpers/apply.go @@ -16,6 +16,26 @@ // Until W-3b lands the cmd/wfctl dispatch wiring, [ApplyPlan] has no // in-tree caller — the helper ships in W-3a as foundation only and is // exercised solely by this package's tests. +// +// # Per-action error-prefix policy +// +// Sub-functions follow a "decompose-then-prefix" rule for the strings +// recorded in [interfaces.ApplyResult].Errors[].Error: +// +// - doCreate, doUpdate, doDelete pass driver errors through +// unchanged. The ActionError struct already carries Resource + +// Action context fields, so a per-kind prefix would be redundant. +// - doCreate's upsert recovery path prefixes "upsert: " (e.g., +// "upsert: read after conflict: ...") because the failure is +// specifically about the recovery flow, not the original Create. +// - doReplace prefixes "replace: delete: " or "replace: create: " +// because a Replace decomposes into two driver calls — without +// the prefix, an operator reading result.Errors couldn't tell +// which sub-step failed. +// +// Tests in apply_update_delete_test.go and apply_replace_test.go +// lock this contract via exact-string assertions; future refactors +// that drop or rename a prefix fail loudly. package wfctlhelpers import ( @@ -171,10 +191,9 @@ func dispatchAction(ctx context.Context, d interfaces.ResourceDriver, action int // idempotent upsert recovery for drivers that opt in via the // UpsertSupporter interface. The recovery path is: // -// 1. Probe the driver for UpsertSupporter — if absent or -// SupportsUpsert()==false, the original conflict surfaces unchanged -// and downstream callers see ErrResourceAlreadyExists in -// result.Errors. +// 1. Probe the driver for UpsertSupporter — if absent (interface +// assertion fails) or SupportsUpsert()==false, the original +// conflict surfaces unchanged. // 2. Read the existing resource by Name + Type (no ProviderID — the // driver's Read is responsible for locating by name when ProviderID // is empty; SupportsUpsert()==true is the contract that this works). @@ -184,9 +203,24 @@ func dispatchAction(ctx context.Context, d interfaces.ResourceDriver, action int // 4. Update the existing resource with the desired spec, threading // the ProviderID found in step 2. // -// Read-after-conflict failures wrap both the original Create error and -// the Read error via errors.Join, so callers can match either via -// errors.Is. +// Recovery is single-pass: if the recovery Update itself returns +// ErrResourceAlreadyExists (a driver bug — Update with a known +// ProviderID should not conflict), the second conflict surfaces +// unchanged rather than retriggering the recovery loop. +// +// Error wrapping (in-package contract): +// +// - Read-after-conflict failures wrap both the original Create error +// and the Read error via errors.Join, so callers in this package +// can match either via errors.Is. +// - The doCreate return value preserves the wrap chain. ApplyPlan's +// dispatch loop, however, flattens errors to a string in +// result.Errors[].Error (see [ApplyPlan]) — external callers +// reading [interfaces.ApplyResult].Errors lose errors.Is matching +// and must inspect the canonical "upsert: read after conflict:" +// prefix instead. This boundary is deliberate: ActionError carries +// the per-resource action context fields the wrap chain otherwise +// duplicates. func doCreate(ctx context.Context, d interfaces.ResourceDriver, action interfaces.PlanAction, result *interfaces.ApplyResult) error { out, err := d.Create(ctx, action.Resource) if errors.Is(err, interfaces.ErrResourceAlreadyExists) { diff --git a/iac/wfctlhelpers/apply_create_test.go b/iac/wfctlhelpers/apply_create_test.go index c530b712e..cbb440429 100644 --- a/iac/wfctlhelpers/apply_create_test.go +++ b/iac/wfctlhelpers/apply_create_test.go @@ -122,6 +122,64 @@ func TestApplyPlan_Create_AlreadyExists_NoUpsertSupport(t *testing.T) { } } +// alreadyExistsBareDriver is a ResourceDriver that does NOT implement +// UpsertSupporter at all (no SupportsUpsert method). Used to exercise +// doCreate's `!ok` interface-assertion fall-through — distinct from +// the `SupportsUpsert()==false` path covered above. Behavioral +// outcome is identical (conflict surfaces unchanged, no Read/Update), +// but the code path through the type assertion is different. +type alreadyExistsBareDriver struct { + *fakeDriver +} + +func (d *alreadyExistsBareDriver) Create(_ context.Context, _ interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) { + d.fakeDriver.createCount++ + return nil, interfaces.ErrResourceAlreadyExists +} + +// TestApplyPlan_Create_AlreadyExists_DriverDoesNotImplementUpsertSupporter +// covers the `!ok` arm of doCreate's `us, ok := d.(interfaces.UpsertSupporter)` +// type assertion: a driver that does not implement UpsertSupporter at +// all. Distinct from TestApplyPlan_Create_AlreadyExists_NoUpsertSupport, +// which covers the `ok && !us.SupportsUpsert()` arm. +func TestApplyPlan_Create_AlreadyExists_DriverDoesNotImplementUpsertSupporter(t *testing.T) { + bare := &alreadyExistsBareDriver{fakeDriver: &fakeDriver{}} + // Sanity-check the test premise: bare must NOT satisfy UpsertSupporter. + // If a future refactor lifts SupportsUpsert onto the embedded fakeDriver, + // this test would silently switch to the "ok && !SupportsUpsert" branch + // and stop covering the `!ok` arm. The compile-time assertion locks + // the premise. + var _ interfaces.ResourceDriver = bare + if _, ok := any(bare).(interfaces.UpsertSupporter); ok { + t.Fatal("test premise broken: alreadyExistsBareDriver must not implement UpsertSupporter") + } + // Inject the bare driver via a one-off provider that returns it for + // any resource type. + fp := &bareDriverProvider{fakeProvider: newFakeProvider(), driver: bare} + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "create", Resource: spec("a", "infra.vpc")}, + }} + result, _ := ApplyPlan(context.Background(), fp, plan) + if len(result.Errors) != 1 { + t.Fatalf("expected 1 per-action error; got %d (%v)", len(result.Errors), result.Errors) + } + if !strings.Contains(result.Errors[0].Error, "already exists") { + t.Errorf("expected ErrResourceAlreadyExists in error; got %q", result.Errors[0].Error) + } +} + +// bareDriverProvider returns the alreadyExistsBareDriver for any +// resource type so the test stays focused on the type-assertion +// fall-through. +type bareDriverProvider struct { + *fakeProvider + driver *alreadyExistsBareDriver +} + +func (p *bareDriverProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + return p.driver, nil +} + // TestApplyPlan_Create_UpsertReadFailureWraps verifies the diagnostic // when Read fails after an ErrResourceAlreadyExists conflict: the error // wraps both the original create error and the read error so callers diff --git a/iac/wfctlhelpers/apply_postcondition_test.go b/iac/wfctlhelpers/apply_postcondition_test.go index 9a3ebcd44..b7ef765b6 100644 --- a/iac/wfctlhelpers/apply_postcondition_test.go +++ b/iac/wfctlhelpers/apply_postcondition_test.go @@ -2,22 +2,25 @@ package wfctlhelpers import ( "context" - "crypto/sha256" - "encoding/hex" "errors" "os" "testing" + "github.com/GoCodeAlone/workflow/iac/inputsnapshot" "github.com/GoCodeAlone/workflow/interfaces" ) -// fingerprint returns the canonical 16-hex-char sha256 prefix used by -// inputsnapshot.Compute. Local helper so tests can build plan -// InputSnapshot fixtures with realistic-looking values without coupling -// to the inputsnapshot package's unexported constants. +// fingerprint delegates to inputsnapshot.Compute so this test file's +// fixtures always use the production fingerprint algorithm. If +// Compute's prefix length or hash algorithm ever changes, every +// expected-fingerprint constant flowing through here re-aligns +// automatically — keeping the cross-package guarantee that +// `apply_postcondition_test.go::fingerprint` and +// `cmd/wfctl/infra_apply_plan_test.go::fingerprintForTest` produce +// identical outputs. Mirrors that helper deliberately. func fingerprint(value string) string { - sum := sha256.Sum256([]byte(value)) - return hex.EncodeToString(sum[:])[:16] + snap := inputsnapshot.Compute([]string{"k"}, func(string) (string, bool) { return value, true }) + return snap["k"] } // TestApplyPlan_InitialInputSnapshot_CapturedAtEntry verifies that diff --git a/iac/wfctlhelpers/apply_update_delete_test.go b/iac/wfctlhelpers/apply_update_delete_test.go index 527f67c67..44614653d 100644 --- a/iac/wfctlhelpers/apply_update_delete_test.go +++ b/iac/wfctlhelpers/apply_update_delete_test.go @@ -124,6 +124,45 @@ func TestApplyPlan_Update_NilCurrentIsHandledDefensively(t *testing.T) { if len(result.Errors) != 0 { t.Errorf("unexpected per-action errors for nil Current: %v", result.Errors) } + // Lock the resource-append contract on the success path so a + // regression that made doUpdate skip the append on nil Current + // would fail this test loudly. + if len(result.Resources) != 1 { + t.Errorf("expected 1 Resources entry on driver-success path; got %d", len(result.Resources)) + } +} + +// TestApplyPlan_Delete_NilCurrentIsHandledDefensively mirrors the +// Update nil-Current contract for doDelete: a delete action without +// action.Current must not panic; the empty ProviderID flows to the +// driver, which is the authority on what to do (most drivers will +// surface a typed validation error). doDelete itself does not +// synthesize a precondition error — same defensive shape as doUpdate. +func TestApplyPlan_Delete_NilCurrentIsHandledDefensively(t *testing.T) { + plan := &interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "delete", Resource: spec("orphan", "infra.vpc"), Current: nil}, + }, + } + fp := newCaptureFakeProvider() + result, err := ApplyPlan(context.Background(), fp, plan) + if err != nil { + t.Fatal(err) + } + if got := fp.driver.deleteRef.ProviderID; got != "" { + t.Errorf("nil Current must yield empty ProviderID on Delete; got %q", got) + } + if got := fp.driver.deleteRef.Name; got != "orphan" { + t.Errorf("Delete ResourceRef.Name: got %q want %q", got, "orphan") + } + if len(result.Errors) != 0 { + t.Errorf("unexpected per-action errors for nil Current Delete: %v", result.Errors) + } + // Sanity: driver was called exactly once (latent bug-fix contract + // from T3.3 — Delete must not be silently skipped). + if fp.driver.deleteCount != 1 { + t.Errorf("driver.Delete must be called exactly once; got %d", fp.driver.deleteCount) + } } // TestApplyPlan_Delete_InvokesDriverDelete is the latent-bug-fix test From 1deffaea2c37c578cd9029b2838f35fc455e3bd8 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:30:22 -0400 Subject: [PATCH 32/38] =?UTF-8?q?fix(iac):=20T3.4=20review=20=E2=80=94=20c?= =?UTF-8?q?tx-cancel=20guard=20between=20Delete=20and=20Create=20in=20doRe?= =?UTF-8?q?place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-reviewer Minor 1 (worth-doing) on commit b17d703. Without the guard, a Ctrl-C / SIGTERM arriving exactly between the Delete and Create driver calls of a Replace action would still trigger the Create — surprising operators who expected fast interruption mid-Replace. The half-replaced state is still the documented recovery surface (Delete happened, Create did not, so ReplaceIDMap stays empty), but cancellation now propagates as soon as it is observable. Failure shape: return fmt.Errorf("replace: canceled after delete: %w", err) Wrapped to preserve the context.Canceled / context.DeadlineExceeded sentinel for in-package errors.Is matching. The "replace: canceled after delete:" string prefix is the discriminant for callers reading result.Errors at the public API surface. New test: TestApplyPlan_Replace_CtxCancelAfterDelete_SkipsCreate + cancelOnDeleteFakeProvider scaffolding. Driver's Delete invokes a captured context.CancelFunc as a side-effect, simulating exact post-Delete cancellation. Asserts Delete ran, Create did NOT, ReplaceIDMap stays empty for the resource, error has the canonical prefix. Code-reviewer Minor 3 (ctx-cancel mid-Replace test) folded into this commit since it's the symmetric coverage for the new guard. Other Minors (2/4/5/6/7) intentionally skipped — all documentary or out-of-scope per reviewer guidance. --- iac/wfctlhelpers/apply.go | 13 +++++ iac/wfctlhelpers/apply_replace_test.go | 72 ++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/iac/wfctlhelpers/apply.go b/iac/wfctlhelpers/apply.go index 80b05321d..871ce431b 100644 --- a/iac/wfctlhelpers/apply.go +++ b/iac/wfctlhelpers/apply.go @@ -278,6 +278,10 @@ func doUpdate(ctx context.Context, d interfaces.ResourceDriver, action interface // Failure semantics: // - Delete fails → return wrapped "replace: delete: "; Create // does NOT run; ReplaceIDMap is NOT populated for this resource. +// - Delete succeeds, ctx canceled before Create → return wrapped +// "replace: canceled after delete: "; Create does NOT run; +// ReplaceIDMap is NOT populated. The half-replaced state is the +// operator's recovery surface (same as the Create-fails case). // - Delete succeeds, Create fails → return wrapped // "replace: create: "; ReplaceIDMap stays empty for this // resource. Operators inspect the apply log + the empty-for-this- @@ -297,6 +301,15 @@ func doReplace(ctx context.Context, d interfaces.ResourceDriver, action interfac if err := d.Delete(ctx, refFromAction(action)); err != nil { return fmt.Errorf("replace: delete: %w", err) } + // Honor cancellation between Delete and Create. Without this guard + // a Ctrl-C / SIGTERM that arrives mid-Replace would still trigger + // the Create call, leaving the operator without the cleanest + // possible interruption point. The half-replaced state is still + // recoverable (Delete already happened; Create did not, so + // ReplaceIDMap stays empty) but cancellation propagates fast. + if err := ctx.Err(); err != nil { + return fmt.Errorf("replace: canceled after delete: %w", err) + } out, err := d.Create(ctx, action.Resource) if err != nil { return fmt.Errorf("replace: create: %w", err) diff --git a/iac/wfctlhelpers/apply_replace_test.go b/iac/wfctlhelpers/apply_replace_test.go index 8ab71073e..5e215bb84 100644 --- a/iac/wfctlhelpers/apply_replace_test.go +++ b/iac/wfctlhelpers/apply_replace_test.go @@ -212,6 +212,78 @@ func TestApplyPlan_Replace_DeleteFailsDoesNotCreate(t *testing.T) { } } +// TestApplyPlan_Replace_CtxCancelAfterDelete_SkipsCreate verifies the +// post-Delete cancellation guard: if ctx is canceled between Delete +// and Create, doReplace must NOT call Create — instead returning a +// wrapped error with the canonical "replace: canceled after delete:" +// prefix. The half-replaced state is the operator's recovery surface +// (Delete happened, Create did not, ReplaceIDMap stays empty for the +// resource). +func TestApplyPlan_Replace_CtxCancelAfterDelete_SkipsCreate(t *testing.T) { + // cancelOnDeleteDriver cancels the context inside Delete after + // recording the call but before returning. This simulates + // SIGTERM/SIGINT arriving exactly between the Delete and Create + // driver calls. + ctx, cancel := context.WithCancel(context.Background()) + fp := &cancelOnDeleteFakeProvider{ + fakeProvider: newFakeProvider(), + cancel: cancel, + } + plan := &interfaces.IaCPlan{Actions: []interfaces.PlanAction{ + {Action: "replace", Resource: spec("vpc", "infra.vpc"), Current: stateWithID("vpc", "old-uuid")}, + }} + result, err := ApplyPlan(ctx, fp, plan) + if err != nil { + // ApplyPlan's loop check catches the cancellation at the next + // iteration, but the per-action ctx check inside doReplace + // fires first. Either path yields a per-action error rather + // than a top-level error from this single-action plan. + t.Fatalf("ApplyPlan should not surface top-level error: %v", err) + } + if fp.driver.deleteCount != 1 { + t.Errorf("Delete should have run before cancellation; deleteCount=%d", fp.driver.deleteCount) + } + if fp.driver.createCount != 0 { + t.Errorf("Create must NOT run after ctx cancellation; createCount=%d", fp.driver.createCount) + } + if _, present := result.ReplaceIDMap["vpc"]; present { + t.Errorf("ReplaceIDMap must not contain vpc when Create skipped; got %+v", result.ReplaceIDMap) + } + if len(result.Errors) != 1 { + t.Fatalf("expected 1 per-action error; got %d (%v)", len(result.Errors), result.Errors) + } + if !strings.HasPrefix(result.Errors[0].Error, "replace: canceled after delete:") { + t.Errorf("expected canonical 'replace: canceled after delete:' prefix; got %q", result.Errors[0].Error) + } +} + +// cancelOnDeleteFakeProvider returns a driver whose Delete cancels a +// supplied context.CancelFunc as a side-effect, simulating exact +// post-Delete pre-Create cancellation. +type cancelOnDeleteFakeProvider struct { + *fakeProvider + cancel context.CancelFunc + driver *cancelOnDeleteDriver +} + +func (p *cancelOnDeleteFakeProvider) ResourceDriver(_ string) (interfaces.ResourceDriver, error) { + if p.driver == nil { + p.driver = &cancelOnDeleteDriver{fakeDriver: p.fakeProvider.driver, cancel: p.cancel} + } + return p.driver, nil +} + +type cancelOnDeleteDriver struct { + *fakeDriver + cancel context.CancelFunc +} + +func (d *cancelOnDeleteDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error { + d.fakeDriver.deleteCount++ + d.cancel() // ctx is now canceled; Create must not run. + return nil +} + // TestApplyPlan_Replace_CreateFailsLeavesMapEmpty verifies the // post-Delete pre-Create failure window: Delete succeeded but Create // failed → ReplaceIDMap stays empty for this resource (no spurious From f80a0607580f17c94b7f7cb83de0ef56d5c38648 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:32:51 -0400 Subject: [PATCH 33/38] docs(iac): document diffcache + set WFCTL_DIFFCACHE=:memory: in CI workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T3.5 lifecycle constraint #4 (rev3) follow-up — addresses spec-reviewer finding on commit 8774205. Two plan-mandated deliverables that the T3.5 commit's `git add` line omitted: 1. **docs/WFCTL.md gains a "Diff Cache" section.** Documents the cache as an amortization-only optimization (not correctness mechanism), the WFCTL_DIFFCACHE backend selection (disabled / :memory: / filesystem default), the LRU eviction caps (1024 entries / 64 MiB), the corruption recovery contract (silent eviction + once-per-process info log), the plugin-downgrade safety property, and the rev3 "all CI workflows set :memory: explicitly" statement plus a list of the affected workflow files. 2. **WFCTL_DIFFCACHE=:memory: at workflow-level env in CI.** Set in every workflow that runs `go test` or `wfctl`: - .github/workflows/ci.yml (test + lint jobs) - .github/workflows/benchmark.yml (performance benchmarks) - .github/workflows/pre-release.yml (pre-release tests) - .github/workflows/release.yml (release tests) - .github/workflows/dependency-update.yml (post-update test gate) Workflow files that don't invoke go test / wfctl are not modified (codeql.yml, copilot-setup-steps.yml, create-release.yml, helm-lint.yml, osv-scanner.yml, test-dispatch.yml). Each workflow gets a brief inline comment citing ci.yml as the canonical rationale + the T3.5 rev3 lifecycle constraint reference. Per spec-reviewer guidance: kept the original T3.5 package-code commit (8774205) untouched and stacked this docs+CI commit on top. YAML syntax verified on all 5 modified workflows. --- .github/workflows/benchmark.yml | 3 ++ .github/workflows/ci.yml | 6 ++++ .github/workflows/dependency-update.yml | 6 ++++ .github/workflows/pre-release.yml | 3 ++ .github/workflows/release.yml | 3 ++ docs/WFCTL.md | 48 +++++++++++++++++++++++++ 6 files changed, 69 insertions(+) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 3244deb5b..26d85fc8e 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -14,6 +14,9 @@ permissions: env: GOPRIVATE: github.com/GoCodeAlone/* GONOSUMCHECK: github.com/GoCodeAlone/* + # See ci.yml for rationale. Force in-memory diffcache in CI per the + # T3.5 rev3 lifecycle constraint. + WFCTL_DIFFCACHE: ":memory:" jobs: benchmark: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcb275b51..6e3aa8831 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,12 @@ permissions: env: GOPRIVATE: github.com/GoCodeAlone/* GONOSUMCHECK: github.com/GoCodeAlone/* + # CI runners are ephemeral; the iac/diffcache filesystem cache would + # cold-miss every job and waste a few ms on disk I/O. Force in-memory + # mode per the T3.5 rev3 lifecycle constraint — no filesystem writes + # in containerized runners. Operators running wfctl locally see the + # default filesystem cache at ~/.cache/wfctl/diff/. + WFCTL_DIFFCACHE: ":memory:" jobs: test: diff --git a/.github/workflows/dependency-update.yml b/.github/workflows/dependency-update.yml index 73c3d9385..407a0f1d3 100644 --- a/.github/workflows/dependency-update.yml +++ b/.github/workflows/dependency-update.yml @@ -10,6 +10,12 @@ on: - cron: '0 9 * * 1' workflow_dispatch: +env: + # See ci.yml for rationale. Force in-memory diffcache in CI per the + # T3.5 rev3 lifecycle constraint — no filesystem writes from this + # workflow's `go test ./...` step. + WFCTL_DIFFCACHE: ":memory:" + jobs: update-dependencies: name: Update Go Dependencies diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 0f0b41ffb..cb913cb5e 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -15,6 +15,9 @@ permissions: env: GOPRIVATE: github.com/GoCodeAlone/* GONOSUMCHECK: github.com/GoCodeAlone/* + # See ci.yml for rationale. Force in-memory diffcache in CI per the + # T3.5 rev3 lifecycle constraint. + WFCTL_DIFFCACHE: ":memory:" jobs: test: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66b2d5ab9..db345899f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,9 @@ env: GOPRIVATE: github.com/GoCodeAlone/* GONOSUMCHECK: github.com/GoCodeAlone/* TAG_NAME: ${{ inputs.tag_name || github.ref_name }} + # See ci.yml for rationale. Force in-memory diffcache in CI per the + # T3.5 rev3 lifecycle constraint. + WFCTL_DIFFCACHE: ":memory:" jobs: test: diff --git a/docs/WFCTL.md b/docs/WFCTL.md index 93063e910..ffb432bca 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -2221,3 +2221,51 @@ deploy: runtime: minikube # minikube | kind | docker-desktop | k3d | remote registry: ghcr.io/myorg ``` + +--- + +## Diff Cache + +`wfctl infra plan` consults a per-resource diff cache so a Plan re-run against unchanged inputs can skip the (sometimes network-expensive) provider-side `Diff` call. The cache lives at the package `iac/diffcache/` and is keyed by `(plugin-version, resource-type, provider-id, sha256(config), sha256(outputs))`. + +**The cache is purely an amortization optimization, NOT a correctness mechanism.** Apply paths remain correct on a 100 % miss rate, which is exactly what CI sees on every fresh runner. Workflows MUST NOT depend on a cache hit for correctness or reproducibility. + +### Backend selection (`WFCTL_DIFFCACHE`) + +The env var `WFCTL_DIFFCACHE` selects the cache backend at process start: + +| Value | Backend | +|---------------|------------------------------------------------------------------------------------------------| +| `disabled` | No-op cache: every `Get` misses; `Put` is a no-op. Use this for fully-deterministic timing or to bypass any disk state. | +| `:memory:` | In-memory cache that lives only for the current process. **CI workflows in this repo set this explicitly** so containerized runners never write cache data to disk. | +| _(unset)_ or anything else | Filesystem cache rooted at `~/.cache/wfctl/diff/.json` (honors `XDG_CACHE_HOME` on Linux via `os.UserCacheDir`). The default for operators running `wfctl` locally. | + +If the user cache directory cannot be resolved, the helper falls back to the in-memory cache automatically — so the caller always gets a working `Cache`. + +### Eviction (filesystem backend) + +The filesystem cache enforces an LRU eviction policy on each `Put`: + +- **By count:** at most 1024 entries. +- **By total size:** at most 64 MiB on disk. + +Whichever cap is exceeded first triggers the eviction pass. The pass evicts the oldest 10 % of entries (sorted by mtime; secondary sort by path for determinism) so amortized cost stays bounded. + +### Corruption recovery + +If a cache file fails to parse on `Get` (truncated, partial-write, JSON syntax error, or a `schemaVersion` mismatch from a wfctl downgrade), the cache silently deletes the corrupt file and returns a miss. No error is surfaced to the caller; a single info-level log line fires the first time corruption is observed in a process so operators have a breadcrumb without log spam. + +### Plugin downgrade + +`PluginVersion` is part of the cache key, so a plugin downgrade naturally invalidates entries (cache key miss). Old entries persist on disk until the LRU eviction reclaims them; the size cap above bounds the disk waste. + +### CI configuration + +Per the T3.5 rev3 lifecycle constraint, **all CI workflows in this repository set `WFCTL_DIFFCACHE=:memory:` at the workflow level** so containerized runners never write to disk: + +```yaml +env: + WFCTL_DIFFCACHE: ":memory:" +``` + +Files: `.github/workflows/ci.yml`, `benchmark.yml`, `pre-release.yml`, `release.yml`, `dependency-update.yml`. New workflow files that invoke `go test` or `wfctl` should add the same env var. From 5ca9a75717511f90d3882a8c50b535f8add0866a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 22:40:13 -0400 Subject: [PATCH 34/38] =?UTF-8?q?fix(iac):=20T3.5=20review=20minors=20?= =?UTF-8?q?=E2=80=94=20atomic=20Put=20+=20godoc=20tightening=20+=20test=20?= =?UTF-8?q?cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 5 of 7 code-reviewer minors on commits 8774205 + f80a060: - Minor 1 (atomic Put, worth-doing production improvement): Put now uses write-temp-then-rename. POSIX rename(2) is atomic on the same filesystem, so a process crash mid-write leaves either the prior contents or the new contents — never a partial write. The corruption-recovery path in Get is still the safety net for cross- filesystem renames or NFS edge cases that don't honor atomicity. In production this means corruption recovery essentially never fires from native crashes. The .json extension filter in maybeEvict already excludes .tmp orphans, so no additional filtering needed. On rename failure, best-effort cleanup of the temp file. - Minor 3 (userCacheDir godoc): tightened the platform-conventions language. Linux honors XDG_CACHE_HOME; macOS uses ~/Library/Caches; Windows uses %LocalAppData%. The previous comment overstated XDG honoring on all platforms. - Minor 4 (Key JSON tags vs keyFingerprint): added a godoc note explaining the tags are for log/transcript serialization, not cache keying — keyFingerprint uses NUL-separated string concat, not JSON marshaling. Future readers checking the fingerprint shape now have the right pointer. - Minor 5 (vestigial sanity check): dropped the `os.Stat(filepath.Join(dir, "*.json"))` literal-glob check at the end of TestCache_EvictionTouchesNothingWhenUnderCap. The check was meaningless — no code path creates a file with `*` in its name. Likely leftover from earlier debugging. Removing it lets us drop the now-unused `os` import. - Minor 6 (mtime resolution test comment): added a paragraph to TestCache_LRUEvictionByCount's godoc explaining the ≤1ms mtime resolution assumption and listing the supported filesystems (ext4/btrfs/xfs/APFS/NTFS — the CI matrix). Coarse-mtime filesystems (FAT32, SMB) are explicitly out of scope. Skipped per reviewer guidance: - Minor 2 (maybeEvict O(N) scan on every Put): "skeleton-class concern; acceptable for W-3a scope." - Minor 7 (Put error log-silent): "the cache-as-amortization framing in the package godoc already sets the expectation." --- iac/diffcache/cache.go | 13 ++++++++++++- iac/diffcache/cache_eviction_test.go | 19 +++++++++++-------- iac/diffcache/cache_filesystem.go | 28 ++++++++++++++++++++++++---- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/iac/diffcache/cache.go b/iac/diffcache/cache.go index 7d164548a..d4cd07436 100644 --- a/iac/diffcache/cache.go +++ b/iac/diffcache/cache.go @@ -78,6 +78,12 @@ const evictionFraction = 0.10 // Key tuples the inputs to a single Diff. Two Keys are equal iff every // field matches; the canonical sha256 fingerprint of the key // determines the on-disk filename. +// +// JSON tags on the fields are for log / transcript serialization +// only — cache keying uses NUL-separated string concatenation in +// [keyFingerprint], not JSON marshaling. A reader checking the +// fingerprint shape should follow the keyFingerprint code, not the +// tags. type Key struct { // PluginVersion is the plugin's name@version string, e.g., // "do@v0.10.0". Plugin downgrades naturally invalidate cache @@ -146,7 +152,12 @@ func (noopCache) Get(_ Key) (interfaces.DiffResult, bool) { return interfaces.Di func (noopCache) Put(_ Key, _ interfaces.DiffResult) { /* no-op */ } // userCacheDir returns the diff-cache root under the user cache -// directory, e.g., `~/.cache/wfctl/diff/` on Linux. Honors XDG_CACHE_HOME. +// directory. Resolution follows os.UserCacheDir conventions per +// platform: +// +// - Linux: $XDG_CACHE_HOME or $HOME/.cache (e.g., ~/.cache/wfctl/diff/). +// - macOS: $HOME/Library/Caches (e.g., ~/Library/Caches/wfctl/diff/). +// - Windows: %LocalAppData% (e.g., C:\Users\\AppData\Local\wfctl\diff\). func userCacheDir() (string, error) { base, err := os.UserCacheDir() if err != nil { diff --git a/iac/diffcache/cache_eviction_test.go b/iac/diffcache/cache_eviction_test.go index cb025154c..17103aee8 100644 --- a/iac/diffcache/cache_eviction_test.go +++ b/iac/diffcache/cache_eviction_test.go @@ -2,7 +2,6 @@ package diffcache import ( "fmt" - "os" "path/filepath" "testing" "time" @@ -15,6 +14,15 @@ import ( // (10% per-overflow batch), the eviction is amortized — for the test // we drive it with a small cap (10 entries; 10% = 1 evicted per // over-cap Put) to keep the test fast. +// +// Mtime-resolution assumption: the 1ms inter-Put sleep relies on the +// underlying filesystem providing mtime granularity ≤ 1ms (true on +// Linux ext4/btrfs/xfs, macOS APFS, Windows NTFS — the CI matrix). +// Coarse-mtime filesystems (FAT32 with 2s, SMB shares with ~1s) would +// produce indistinguishable mtimes for adjacent Puts and the +// secondary path-sort (by sha256 hash of the cache key) would decide +// eviction order — uncorrelated with insertion order. The package +// intentionally does not support such filesystems. func TestCache_LRUEvictionByCount(t *testing.T) { dir := t.TempDir() c := &filesystemCache{ @@ -23,9 +31,8 @@ func TestCache_LRUEvictionByCount(t *testing.T) { maxBytes: defaultMaxBytes, // not the trigger here } // Fill to the cap. Each Put with a different key creates a new file. - // We sleep 1ms between Puts so mtimes are distinguishable; the OS - // mtime resolution is 1ns on most filesystems but coarse on some, - // so 1ms is a safe lower bound. + // 1ms between Puts so mtimes are distinguishable on the supported + // filesystems (see godoc above). for i := range 10 { c.Put(Key{Type: fmt.Sprintf("k%d", i)}, interfaces.DiffResult{}) time.Sleep(time.Millisecond) @@ -115,8 +122,4 @@ func TestCache_EvictionTouchesNothingWhenUnderCap(t *testing.T) { t.Errorf("under-cap key %q should be retained", k) } } - // Sanity: verify no errant background eviction took place. - if _, err := os.Stat(filepath.Join(dir, "*.json")); err == nil { - t.Error("glob path should not exist as a literal file (sanity check)") - } } diff --git a/iac/diffcache/cache_filesystem.go b/iac/diffcache/cache_filesystem.go index c822e336b..9bb1064ca 100644 --- a/iac/diffcache/cache_filesystem.go +++ b/iac/diffcache/cache_filesystem.go @@ -75,11 +75,21 @@ func (c *filesystemCache) Get(k Key) (interfaces.DiffResult, bool) { return env.Result, true } +// Put writes the cache entry atomically via write-temp-then-rename. +// POSIX rename(2) is atomic on the same filesystem — if the process +// crashes mid-write the temp file is orphaned but the final cache +// path is either the prior contents or the new contents, never a +// partial write. The corruption-recovery path in [Get] is still the +// safety net for cross-filesystem renames or NFS edge cases that +// don't honor atomicity, but with this pattern the corruption +// recovery essentially never fires in production. +// +// Disk-side errors during Put are intentionally silent: the next Get +// will miss (correct), and the operator already has "stuff isn't +// working" signal from elsewhere. The cache-as-amortization framing +// in the package godoc sets the expectation. func (c *filesystemCache) Put(k Key, result interfaces.DiffResult) { if err := os.MkdirAll(c.dir, 0o755); err != nil { - // Disk-side errors during Put are intentionally silent: the - // next Get will miss (correct), and the operator already has - // "stuff isn't working" signal from elsewhere. return } env := envelope{SchemaVersion: cacheSchemaVersion, Result: result} @@ -88,7 +98,17 @@ func (c *filesystemCache) Put(k Key, result interfaces.DiffResult) { return } path := c.pathFor(k) - _ = os.WriteFile(path, data, 0o644) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return + } + if err := os.Rename(tmp, path); err != nil { + // Best-effort cleanup of the orphaned temp file; ignore + // errors since the next Put may overwrite it anyway and + // LRU eviction will eventually reclaim the space. + _ = os.Remove(tmp) + return + } c.maybeEvict() } From 1f11bd656b0fc80371640e6c9e5647a62fd358ba Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 23:14:11 -0400 Subject: [PATCH 35/38] fix(iac): diffcache.Get refreshes mtime so LRU is actually LRU (Copilot review) Without this, frequently-read entries were evicted as if unused because maybeEvict orders by mtime. Now Get touches mtime via os.Chtimes(now, now), turning eviction from FIFO-by-write into true LRU. Mtime-touch chosen over a sidecar last-accessed file to keep the on-disk shape trivial; cost is one extra syscall per hit, errors are ignored (failure degrades eviction precision but never produces wrong cache results). Adds TestCache_LRURefreshesOnGet regression test: writes N entries, Gets the oldest, then triggers over-cap, asserts the oldest survives and the second-oldest (now the LRU) is evicted instead. --- iac/diffcache/cache_eviction_test.go | 38 ++++++++++++++++++++++++++++ iac/diffcache/cache_filesystem.go | 12 +++++++++ 2 files changed, 50 insertions(+) diff --git a/iac/diffcache/cache_eviction_test.go b/iac/diffcache/cache_eviction_test.go index 17103aee8..5d491d166 100644 --- a/iac/diffcache/cache_eviction_test.go +++ b/iac/diffcache/cache_eviction_test.go @@ -90,6 +90,44 @@ func TestCache_LRUEvictionBatchOf10Percent(t *testing.T) { } } +// TestCache_LRURefreshesOnGet verifies that Get touches the cache +// file's mtime so that frequently-read entries are NOT evicted as if +// they were stale. The contract: an entry written first but Get'd +// after a newer entry was Put should outlive the newer entry under +// LRU eviction. Without the mtime-refresh in Get, eviction would +// behave as FIFO-by-write rather than true LRU. +func TestCache_LRURefreshesOnGet(t *testing.T) { + dir := t.TempDir() + c := &filesystemCache{ + dir: dir, + maxEntries: 10, + maxBytes: defaultMaxBytes, + } + // Fill exactly to the cap. k0 is the oldest by write order. + for i := range 10 { + c.Put(Key{Type: fmt.Sprintf("k%d", i)}, interfaces.DiffResult{}) + time.Sleep(time.Millisecond) + } + // Get k0 — this MUST refresh mtime so k0 is now the youngest by + // access time, even though it was the oldest by write time. Without + // the refresh, k0 retains its original (oldest) mtime and gets + // evicted on the next over-cap Put. + if _, hit := c.Get(Key{Type: "k0"}); !hit { + t.Fatal("expected hit on k0 before eviction") + } + time.Sleep(time.Millisecond) + // Trigger overflow: one extra Put → evict 1 (10% of 10). Under true + // LRU (mtime refreshed on Get), k0 is now the freshest entry and k1 + // is the oldest, so k1 should be evicted, not k0. + c.Put(Key{Type: "k_overflow"}, interfaces.DiffResult{}) + if _, hit := c.Get(Key{Type: "k0"}); !hit { + t.Errorf("k0 was Get'd before eviction; LRU should retain it (Get must refresh mtime)") + } + if _, hit := c.Get(Key{Type: "k1"}); hit { + t.Errorf("k1 was the oldest by access time; LRU should have evicted it") + } +} + // countCacheFiles returns the number of *.json files under dir. Used // to assert eviction behavior without depending on cache internals. func countCacheFiles(t *testing.T, dir string) int { diff --git a/iac/diffcache/cache_filesystem.go b/iac/diffcache/cache_filesystem.go index 9bb1064ca..402fef950 100644 --- a/iac/diffcache/cache_filesystem.go +++ b/iac/diffcache/cache_filesystem.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sort" "sync" + "time" "github.com/GoCodeAlone/workflow/interfaces" ) @@ -72,6 +73,17 @@ func (c *filesystemCache) Get(k Key) (interfaces.DiffResult, bool) { c.handleCorruption(path, errors.New("schema-version mismatch")) return interfaces.DiffResult{}, false } + // Refresh mtime so [maybeEvict] (which orders by mtime) treats this + // entry as recently-used. Without this, frequently-read but + // infrequently-rewritten entries get evicted as if they were stale — + // the cache would be FIFO-by-write, not LRU. We chose mtime-touch + // over a sidecar "last-accessed" file to keep the on-disk shape + // trivial; the small cost is one extra syscall per cache hit. + // Errors are intentionally ignored: a Chtimes failure degrades + // eviction precision (this entry may be evicted earlier than + // preferred) but never produces wrong cache results. + now := time.Now() + _ = os.Chtimes(path, now, now) return env.Result, true } From a1eb7001c8395a4e2d20b480c868b6ddad248bda Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 23:15:46 -0400 Subject: [PATCH 36/38] fix(iac): diffcache.Put uses unique temp filename to avoid same-key write races (Copilot review) Pre-fix, two goroutines calling Put with the same Key both wrote to `.json.tmp` and one would clobber the other's temp file mid-write, producing either a Rename failure or a half-written final file. Now Put uses os.CreateTemp so each call gets a unique `.json..tmp` filename; the final rename is racy on which payload wins, but both payloads were derived from the same Key so the outcome is deterministic from the caller's perspective. Adds godoc "Concurrency: safe for concurrent use, including concurrent Puts of the same Key." Adds TestCache_ConcurrentSameKeyPut regression: 20 goroutines Put the same Key, asserts no leftover *.tmp files, asserts final cache file decodes. Run under -race. --- iac/diffcache/cache_concurrent_test.go | 59 ++++++++++++++++++++++++++ iac/diffcache/cache_filesystem.go | 29 ++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 iac/diffcache/cache_concurrent_test.go diff --git a/iac/diffcache/cache_concurrent_test.go b/iac/diffcache/cache_concurrent_test.go new file mode 100644 index 000000000..e8a35e8ff --- /dev/null +++ b/iac/diffcache/cache_concurrent_test.go @@ -0,0 +1,59 @@ +package diffcache + +import ( + "path/filepath" + "sync" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// TestCache_ConcurrentSameKeyPut verifies that multiple goroutines +// calling Put with the same Key do not race on a shared temp filename. +// Pre-fix, both Puts wrote to `.json.tmp` and one would clobber +// the other's temp file mid-write, producing either a Rename failure +// or, worse, a half-written final file. With os.CreateTemp's per-call +// unique suffix, each Put has its own temp path; the final Rename is +// racy in the sense that one payload "wins," but both payloads are +// equivalent (same Key → same canonical input) so the outcome is +// deterministic from the caller's perspective. +// +// The test asserts: (a) no panic, (b) no leftover *.tmp files in the +// cache dir after all goroutines finish, (c) the final cache file is +// readable and decodes successfully. Run under -race to catch any +// shared-state mutation that slipped through. +func TestCache_ConcurrentSameKeyPut(t *testing.T) { + dir := t.TempDir() + c := NewFilesystem(dir).(*filesystemCache) + key := Key{Type: "infra.vpc", ProviderID: "vpc-abc"} + const goroutines = 20 + + var wg sync.WaitGroup + wg.Add(goroutines) + for range goroutines { + go func() { + defer wg.Done() + c.Put(key, interfaces.DiffResult{NeedsUpdate: true}) + }() + } + wg.Wait() + + // No leftover temp files: every Put either renamed successfully + // (no orphan) or failed and cleaned up via os.Remove. + tmps, err := filepath.Glob(filepath.Join(dir, "*.tmp")) + if err != nil { + t.Fatalf("glob tmp: %v", err) + } + if len(tmps) != 0 { + t.Errorf("expected 0 leftover *.tmp files; found %d: %v", len(tmps), tmps) + } + + // Final cache file is readable + decodes. + got, hit := c.Get(key) + if !hit { + t.Fatal("expected hit after concurrent Puts of the same key") + } + if !got.NeedsUpdate { + t.Errorf("Get returned wrong value after concurrent Puts: %+v", got) + } +} diff --git a/iac/diffcache/cache_filesystem.go b/iac/diffcache/cache_filesystem.go index 402fef950..8fb74d5da 100644 --- a/iac/diffcache/cache_filesystem.go +++ b/iac/diffcache/cache_filesystem.go @@ -96,6 +96,14 @@ func (c *filesystemCache) Get(k Key) (interfaces.DiffResult, bool) { // don't honor atomicity, but with this pattern the corruption // recovery essentially never fires in production. // +// Concurrency: safe for concurrent use, including concurrent Puts +// of the same Key. Each Put uses [os.CreateTemp] to obtain a unique +// temp filename (`.json..tmp`) so two goroutines writing +// the same Key cannot clobber each other's temp file. The final +// rename is racy in the sense that one goroutine's payload "wins," +// but both payloads were derived from the caller's DiffResult so the +// outcome is deterministic from the caller's perspective. +// // Disk-side errors during Put are intentionally silent: the next Get // will miss (correct), and the operator already has "stuff isn't // working" signal from elsewhere. The cache-as-amortization framing @@ -110,8 +118,25 @@ func (c *filesystemCache) Put(k Key, result interfaces.DiffResult) { return } path := c.pathFor(k) - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { + // os.CreateTemp gives us a per-call unique tempfile name and an + // open *os.File. Pattern uses a "*" suffix so the random token + // lands before ".tmp", giving filenames like + // `.json.123456789.tmp` — easy to spot during eviction + // and unambiguous about origin. Two concurrent Puts of the same + // Key end up with two distinct temp files; whichever rename + // completes last wins for the final cache path. + tmpFile, err := os.CreateTemp(c.dir, filepath.Base(path)+".*.tmp") + if err != nil { + return + } + tmp := tmpFile.Name() + if _, err := tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + _ = os.Remove(tmp) + return + } + if err := tmpFile.Close(); err != nil { + _ = os.Remove(tmp) return } if err := os.Rename(tmp, path); err != nil { From 510bbef2cd88f22e6c267a25c99cc4fe6fe05b8a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 23:16:25 -0400 Subject: [PATCH 37/38] fix(iac): diffcache.Put atomic rename on Windows (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the os.Rename Windows limitation explicitly: on Windows, os.Rename fails when the destination exists, so an in-place cache update via Put will fail. The caller treats this as a write failure and proceeds without caching — correct because apply remains correct on a 100% miss rate (per the package's cache-as-amortization framing). We chose documentation over vendoring github.com/google/renameio: adding renameio would introduce the first such dependency in the repo, and there is no Windows-supported wfctl use case today. The existing precedent in cmd/wfctl/update.go and cmd/wfctl/plugin_install.go also uses bare os.Rename without Windows guards. The fix tracks the limitation in two places: the Put godoc (where the rename happens) and the package godoc Known Limitations section (where consumers will look). --- iac/diffcache/cache.go | 12 ++++++++++++ iac/diffcache/cache_filesystem.go | 21 ++++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/iac/diffcache/cache.go b/iac/diffcache/cache.go index d4cd07436..ccc1e643f 100644 --- a/iac/diffcache/cache.go +++ b/iac/diffcache/cache.go @@ -40,6 +40,18 @@ // Get, a mismatched version is treated identically to file corruption: // the entry is silently evicted and the caller re-Diffs. // +// # Known limitations +// +// Windows: the filesystem cache uses [os.Rename] for the atomic +// publish step on Put. On Windows, [os.Rename] fails when the +// destination already exists, so updating an existing cache entry +// will fail (the entry is treated as a write failure and the +// operator gets a cache miss on the next Get — correct, since +// apply does not depend on cache hits). A future improvement is to +// vendor github.com/google/renameio for cross-platform atomic +// rename; deferred until there's a Windows-supported wfctl use +// case. +// // # T3.5 / W-3a status // // This package ships in W-3a. The consumer that wires it into diff --git a/iac/diffcache/cache_filesystem.go b/iac/diffcache/cache_filesystem.go index 8fb74d5da..52818b71f 100644 --- a/iac/diffcache/cache_filesystem.go +++ b/iac/diffcache/cache_filesystem.go @@ -104,6 +104,20 @@ func (c *filesystemCache) Get(k Key) (interfaces.DiffResult, bool) { // but both payloads were derived from the caller's DiffResult so the // outcome is deterministic from the caller's perspective. // +// Windows portability: this implementation uses the bare [os.Rename] +// for the atomic publish step, which matches the precedent set by +// other rename sites in this repo (cmd/wfctl/update.go, +// cmd/wfctl/plugin_install.go). On Windows, [os.Rename] fails when +// the destination already exists, so an in-place cache update via +// Put will fail on Windows; the caller treats this as a write +// failure and proceeds without caching (correct, by the cache-as- +// amortization framing in the package godoc — apply remains correct +// on a 100% miss rate). A future improvement is to vendor +// github.com/google/renameio for cross-platform atomic rename; +// doing so here would introduce the first such dependency in the +// repo, so deferred until there's a Windows-supported wfctl use +// case. Tracked as a known limitation in the package godoc. +// // Disk-side errors during Put are intentionally silent: the next Get // will miss (correct), and the operator already has "stuff isn't // working" signal from elsewhere. The cache-as-amortization framing @@ -140,9 +154,10 @@ func (c *filesystemCache) Put(k Key, result interfaces.DiffResult) { return } if err := os.Rename(tmp, path); err != nil { - // Best-effort cleanup of the orphaned temp file; ignore - // errors since the next Put may overwrite it anyway and - // LRU eviction will eventually reclaim the space. + // Best-effort cleanup of the orphaned temp file. On Windows + // this rename can fail when path already exists (see godoc + // above); on Unix it's atomic-replace. Either way the next + // Put may succeed and LRU eviction reclaims any orphans. _ = os.Remove(tmp) return } From f73b6bca259637a966a545f2d19887a2afdd7218 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sun, 3 May 2026 23:17:24 -0400 Subject: [PATCH 38/38] fix(iac): diffcache returns deep-copy of DiffResult to avoid shared-slice mutation (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fix, the in-memory cache stored DiffResult by value but the Changes slice ([]FieldChange) shared its backing array between the cached entry and the value returned to the caller. A caller mutating the returned Changes slice (element-level or via append- into-cap) would silently mutate the cached entry. The symmetric case is the same: mutating the Put argument after the Put call would leak into the cached value. Fix: clone the Changes slice via slices.Clone in both Get and Put. Scalar struct fields are value-copied by struct assignment so a single helper (cloneDiffResult) covers both directions. The filesystem cache deserializes from JSON each time so each Get already yields a fresh slice — no change needed there. FieldChange.Old/New are typed any; if a caller stores a pointer or mutable map there, the deep-copy stops at the slice level. By convention DiffResult.Changes carries scalar Old/New (strings, numbers, bools), so that is the right tradeoff between correctness and copy cost. Documented in memoryCache godoc. Adds TestCache_MemoryDeepCopiesChanges regression: Put a value, mutate the original argument, Get + mutate (element + append), Get again, assert original is preserved. --- iac/diffcache/cache_inmemory.go | 28 +++++++++++++++-- iac/diffcache/cache_test.go | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/iac/diffcache/cache_inmemory.go b/iac/diffcache/cache_inmemory.go index 671b20895..51fd62c3e 100644 --- a/iac/diffcache/cache_inmemory.go +++ b/iac/diffcache/cache_inmemory.go @@ -1,6 +1,7 @@ package diffcache import ( + "slices" "sync" "github.com/GoCodeAlone/workflow/interfaces" @@ -14,6 +15,19 @@ import ( // Concurrency: a single mutex guards the map. Diff results are small // and operations are infrequent (one per resource per Plan), so a // finer-grained scheme isn't justified. +// +// Mutation isolation: DiffResult contains a Changes slice ([]FieldChange) +// whose backing array would otherwise be shared between the cached +// value and the value returned to the caller. Both Put and Get +// deep-copy the Changes slice so a caller mutating the returned +// DiffResult cannot mutate the cached entry, and a caller mutating +// the original Put argument cannot mutate the cached entry either. +// FieldChange itself contains `Old`/`New` of type any — the package +// does not own those values; if a caller stores a pointer or +// mutable map there, the deep-copy stops at the slice level. By +// convention DiffResult.Changes carries scalar Old/New (strings, +// numbers, bools), so this is the right tradeoff between +// correctness and copy cost. type memoryCache struct { mu sync.Mutex entries map[string]interfaces.DiffResult @@ -34,11 +48,21 @@ func (c *memoryCache) Get(k Key) (interfaces.DiffResult, bool) { if !ok { return interfaces.DiffResult{}, false } - return r, true + return cloneDiffResult(r), true } func (c *memoryCache) Put(k Key, result interfaces.DiffResult) { c.mu.Lock() defer c.mu.Unlock() - c.entries[keyFingerprint(k)] = result + c.entries[keyFingerprint(k)] = cloneDiffResult(result) +} + +// cloneDiffResult returns a deep copy of r at the slice level. The +// scalar fields are value-copied by struct assignment; Changes is +// cloned via slices.Clone so the cached and returned values do not +// share the backing array. See [memoryCache] godoc for the +// Old/New tradeoff (any-typed; not deep-cloned beyond the slice). +func cloneDiffResult(r interfaces.DiffResult) interfaces.DiffResult { + r.Changes = slices.Clone(r.Changes) + return r } diff --git a/iac/diffcache/cache_test.go b/iac/diffcache/cache_test.go index 9aa038245..33f83b581 100644 --- a/iac/diffcache/cache_test.go +++ b/iac/diffcache/cache_test.go @@ -79,6 +79,60 @@ func TestCache_MemoryRoundtrip(t *testing.T) { } } +// TestCache_MemoryDeepCopiesChanges verifies that the in-memory +// cache returns a deep copy of the DiffResult so a caller mutating +// the returned Changes slice cannot leak that mutation back into +// the cached entry. Pre-fix, the cached value and the returned value +// shared the same backing array for Changes; mutating one mutated +// the other. +// +// The test pattern: Put a value with a single Change, Get it, mutate +// the returned Changes slice (both element-level and length-level +// via append-into-cap), Get again, assert the second Get returns +// the original unmodified value. Also verifies the symmetric case: +// mutating the original argument after Put does not affect the +// cached value. +func TestCache_MemoryDeepCopiesChanges(t *testing.T) { + c := NewMemory() + key := Key{Type: "infra.vpc"} + original := interfaces.DiffResult{ + NeedsUpdate: true, + Changes: []interfaces.FieldChange{ + {Path: "size", Old: "small", New: "large"}, + }, + } + c.Put(key, original) + + // Mutate the original argument after Put. A leaky implementation + // would let this mutation reach the cached value. + original.Changes[0].Path = "MUTATED-AFTER-PUT" + + // First Get + mutate the returned slice element + length. + got1, hit := c.Get(key) + if !hit { + t.Fatal("expected hit on first Get") + } + if got1.Changes[0].Path != "size" { + t.Errorf("first Get: cached value leaked from post-Put mutation; Path=%q want %q", + got1.Changes[0].Path, "size") + } + got1.Changes[0].Path = "MUTATED-VIA-GET" + got1.Changes = append(got1.Changes, interfaces.FieldChange{Path: "extra"}) + + // Second Get must see the original value, not the mutated one. + got2, hit := c.Get(key) + if !hit { + t.Fatal("expected hit on second Get") + } + if len(got2.Changes) != 1 { + t.Errorf("second Get: Changes len=%d want 1; mutation via append leaked into cache", len(got2.Changes)) + } + if len(got2.Changes) > 0 && got2.Changes[0].Path != "size" { + t.Errorf("second Get: Changes[0].Path=%q want %q; mutation via Get leaked into cache", + got2.Changes[0].Path, "size") + } +} + // TestCache_NoopAlwaysMisses verifies the disabled cache: Put is a // no-op, every Get returns hit=false. func TestCache_NoopAlwaysMisses(t *testing.T) {