Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
324d856
feat(iac): add IaCPlan.SchemaVersion + InputSnapshot + PlanAction.Res…
intel352 May 3, 2026
7c8c3ac
feat(iac): add inputsnapshot.Compute + Snapshot + NewTolerantEnvProvi…
intel352 May 3, 2026
4774c3b
feat(iac): wfctl infra plan writes InputSnapshot to plan.json
intel352 May 3, 2026
295d354
feat(iac): ComputePlan sets PlanAction.ResolvedConfigHash
intel352 May 3, 2026
b442dae
feat(iac): wfctl infra plan warns when plan.json not in .gitignore
intel352 May 3, 2026
43c8ced
feat(iac): typed ErrEnvVarChanged sentinel + plan-stale diagnostic + …
intel352 May 3, 2026
cbc8319
fix(iac): FormatStaleError omits hint when drift is empty (Copilot re…
intel352 May 3, 2026
a116a1c
fix(iac): shorten ErrEnvVarChanged message; FormatStaleError owns use…
intel352 May 3, 2026
a36bba6
fix(iac): soften preservedFingerprint doc — unexported is discipline …
intel352 May 3, 2026
03c81ce
fix(iac): gitignoreCovers uses bytes.NewReader + checks scanner.Err (…
intel352 May 3, 2026
0f764ba
docs(iac): clarify IaCPlan.InputSnapshot only records set vars (Copil…
intel352 May 3, 2026
5ece237
docs(iac): document gitignoreCovers negation false-negative honestly …
intel352 May 4, 2026
351c09c
fix(iac): preservedFingerprint embeds NUL byte to make value-collisio…
intel352 May 4, 2026
5696cb2
test(iac): NewTolerantEnvProvider test uses unique key, no os.Unseten…
intel352 May 4, 2026
9a4e358
docs(iac): clarify ResolvedConfigHash encoding format (Copilot review)
intel352 May 4, 2026
42c1889
test(iac): use realistic 64-hex value for ResolvedConfigHash round-tr…
intel352 May 4, 2026
da9cbe7
feat(iac): wfctl infra apply rejects plans with future schema_version…
intel352 May 4, 2026
9d30e35
fix(iac): gitignoreCovers propagates scan errors so warning surfaces …
intel352 May 4, 2026
77bcff1
test(iac): TolerantEnvProvider test guarantees Unsetenv precondition …
intel352 May 4, 2026
06cae1c
fix(iac): FormatStaleError hint references "your infra config" not li…
intel352 May 4, 2026
0f112be
docs(iac): document collectInfraEnvVarRefs top-level envVars merge ga…
intel352 May 4, 2026
8ae53e4
fix(iac): introduce *StaleError so user output is FormatStaleError-on…
intel352 May 4, 2026
37586b2
docs(iac): ResolvedConfigHash describes current behavior, not unwired…
intel352 May 4, 2026
2f16dc6
fix(iac): warnIfPlanNotGitignored uses actual basename, not literal "…
intel352 May 4, 2026
ef7fe11
fix(iac): bound gitignore walk to enclosing git worktree (Copilot rev…
intel352 May 4, 2026
96e2327
docs(iac): IaCPlan.InputSnapshot acknowledges top-level envVars merge…
intel352 May 4, 2026
dd16006
fix(iac): ComputeDrift sorts result slice by Name for deterministic o…
intel352 May 4, 2026
0a7a58d
docs(iac): note ResolvedConfigHash omitempty=field-absent on disk (Co…
intel352 May 4, 2026
88536a0
perf(iac): Compute capacity hint + hoist gitignoreCovers per-line con…
intel352 May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@ 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"
)

// infraPlanSchemaVersion is the on-disk plan format version this wfctl
// binary writes and is willing to read. runInfraPlan stamps it on every
// emitted plan; runInfraApply rejects plans with a higher version so a
// future schema bump (e.g. W-5 JIT-required plans) fails fast rather than
// being silently mis-read by an older binary.
const infraPlanSchemaVersion = 1

func runInfra(args []string) error {
if len(args) < 1 {
return infraUsage()
Expand Down Expand Up @@ -201,6 +209,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 = infraPlanSchemaVersion

switch *format {
case "markdown":
fmt.Print(formatPlanMarkdown(plan, showSensitive))
Expand All @@ -217,6 +236,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
Expand Down Expand Up @@ -1058,6 +1082,12 @@ func runInfraApply(args []string) error {
if err != nil {
return err
}
// Reject plans whose on-disk schema is newer than this binary
// understands. SchemaVersion == 0 (unset) is grandfathered in for
// plans emitted by wfctl predating the field.
if plan.SchemaVersion > infraPlanSchemaVersion {
return fmt.Errorf("plan schema_version %d is newer than this wfctl supports (max %d) — upgrade wfctl or re-plan with the older format", plan.SchemaVersion, infraPlanSchemaVersion)
}
// Validate that the plan is still current relative to the config.
desired, err := parseInfraResourceSpecsForEnv(cfgFile, envName)
if err != nil {
Expand All @@ -1066,6 +1096,24 @@ 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.
Comment on lines +1099 to +1103
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 {
// *StaleError: Error() yields the canonical FormatStaleError
// output (no sentinel prefix); Unwrap() yields ErrEnvVarChanged
// so errors.Is(err, inputsnapshot.ErrEnvVarChanged) still matches.
return inputsnapshot.NewStaleError(drift)
}
Comment on lines +1109 to +1115
}
currentHash := desiredStateHash(desired)
if plan.DesiredHash != currentHash {
return fmt.Errorf("plan stale: config hash mismatch (run wfctl infra plan again)")
Expand Down
141 changes: 141 additions & 0 deletions cmd/wfctl/infra_apply_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,27 @@ package main
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"

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

// fingerprintForTest delegates to inputsnapshot.Compute so the test always
// uses the production fingerprint algorithm. Re-implementing sha256 + 16-hex
// inline would silently drift if the scheme changed; routing through the
// same function the apply path uses makes that impossible.
func fingerprintForTest(value string) string {
snap := inputsnapshot.Compute([]string{"k"}, func(string) (string, bool) { return value, true })
return snap["k"]
}

// TestInfraApplyConsumesPlan verifies that wfctl infra apply --plan <file>:
// 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).
Expand Down Expand Up @@ -237,6 +248,53 @@ modules:
}
}

// TestInfraApplyConsumesPlan_FutureSchemaRejected verifies that a plan whose
// SchemaVersion is greater than the current binary supports is rejected with
// a clear "newer than this wfctl" message rather than being silently
// mis-read as a v1 plan with stray fields.
func TestInfraApplyConsumesPlan_FutureSchemaRejected(t *testing.T) {
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
- name: my-db
type: infra.database
config:
provider: test-provider
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}

// Plan declares a schema version newer than this binary supports.
plan := interfaces.IaCPlan{
ID: "future-schema",
SchemaVersion: infraPlanSchemaVersion + 1,
DesiredHash: "deadbeef",
Actions: []interfaces.PlanAction{{Action: "create", Resource: interfaces.ResourceSpec{Name: "my-db", Type: "infra.database"}}},
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)
}

err = runInfraApply([]string{"--auto-approve", "--config", cfgPath, "--plan", planPath})
if err == nil {
t.Fatal("expected error for future schema_version, got nil")
}
if !strings.Contains(err.Error(), "schema_version") || !strings.Contains(err.Error(), "newer") {
t.Errorf("error should mention schema_version + newer; got: %v", err)
}
}

// applyCaptureFull is a mock provider that returns a real ApplyResult with
// provisioned resources, enabling state-persistence path testing.
type applyCaptureFull struct {
Expand Down Expand Up @@ -399,6 +457,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) {
Expand Down
100 changes: 100 additions & 0 deletions cmd/wfctl/infra_inputsnapshot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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.
//
// LIMITATION (tracked, not addressed in W-1): top-level
// environments[env].envVars defaults that planResourcesForEnv merges into
// container-style modules are NOT applied here. References that originate
// solely from a top-level envVars default (without appearing in the
// module's own Config map) won't appear in InputSnapshot, so plan-stale
// drift detection will miss those vars changing between plan and apply.
// Closing the gap requires reusing planResourcesForEnv's merge logic
// before walkValueForEnvRefs; deferred to a follow-up that can extend
// ResolveForEnv to expose the merged form.
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)
}
Comment on lines +45 to +59
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
}
Loading
Loading