Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
695a070
feat(iac): plugin manifest gains iacProvider.computePlanVersion (defa…
intel352 May 4, 2026
4df0d1b
fix(iac): T3.0 review — sync.Once-guarded schema cache + tighter iacP…
intel352 May 4, 2026
5039290
feat(iac): add refreshoutputs.Refresh — read-only state output refresh
intel352 May 3, 2026
dcc1ec2
feat(iac): add wfctl infra refresh-outputs subcommand
intel352 May 4, 2026
207924d
feat(iac): apply-time refresh-outputs pre-step (opt-in via WFCTL_REFR…
intel352 May 4, 2026
8bbe4a4
test(iac): concurrency stress test for refreshoutputs.Refresh
intel352 May 4, 2026
e9a200f
docs(wfctl): document infra refresh-outputs subcommand
intel352 May 4, 2026
f42f536
docs(adr): record WFCTL_REFRESH_OUTPUTS ParseBool semantics deviation…
intel352 May 4, 2026
37185d8
Merge branch 'feat/iac-refresh-outputs' into feat/iac-replace-foundation
intel352 May 4, 2026
13a6fad
feat(iac): add ApplyResult.InitialInputSnapshot + InputDriftReport + …
intel352 May 4, 2026
8416498
feat(iac): add wfctlhelpers.ApplyPlan skeleton (4-action dispatch)
intel352 May 4, 2026
d3073d7
fix(iac): T3.0.4 review — correct ReplaceIDMap key direction + lock o…
intel352 May 4, 2026
6dbe501
fix(iac): T3.1 review — strengthen Replace coverage + ctx-cancel + dr…
intel352 May 4, 2026
04d8ad2
fix(wfctl): drop unused crypto/sha256 + encoding/hex from infra_apply…
intel352 May 4, 2026
f5a7ce9
feat(iac): in-process apply unconditional drift postcondition (panic-…
intel352 May 4, 2026
0c30eec
feat(iac): doCreate honors UpsertSupporter for ErrResourceAlreadyExis…
intel352 May 4, 2026
a3fc98b
feat(iac): doUpdate + doDelete actions
intel352 May 4, 2026
b17d703
feat(iac): doReplace populates ApplyResult.ReplaceIDMap
intel352 May 4, 2026
8774205
feat(iac): add diff cache with LRU eviction + corruption recovery
intel352 May 4, 2026
b735f62
fix(iac): T3.1.5/T3.2/T3.3 review minors — helper consistency, type-a…
intel352 May 4, 2026
1deffae
fix(iac): T3.4 review — ctx-cancel guard between Delete and Create in…
intel352 May 4, 2026
f80a060
docs(iac): document diffcache + set WFCTL_DIFFCACHE=:memory: in CI wo…
intel352 May 4, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/dependency-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pre-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 18 additions & 2 deletions cmd/wfctl/infra.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ import (
"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 @@ -214,7 +221,7 @@ func runInfraPlan(args []string) error {
return fmt.Errorf("compute input snapshot: %w", err)
}
plan.InputSnapshot = snap
plan.SchemaVersion = 1
plan.SchemaVersion = infraPlanSchemaVersion

switch *format {
case "markdown":
Expand Down Expand Up @@ -1092,6 +1099,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 @@ -1112,7 +1125,10 @@ func runInfraApply(args []string) error {
}
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))
// *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)
}
}
currentHash := desiredStateHash(desired)
Expand Down
22 changes: 22 additions & 0 deletions cmd/wfctl/infra_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions cmd/wfctl/infra_apply_in_process_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
}
60 changes: 53 additions & 7 deletions cmd/wfctl/infra_apply_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package main

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
Expand All @@ -17,12 +15,13 @@ import (
"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.
// 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 {
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"]
}

// TestInfraApplyConsumesPlan verifies that wfctl infra apply --plan <file>:
Expand Down Expand Up @@ -249,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
10 changes: 10 additions & 0 deletions cmd/wfctl/infra_inputsnapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ import (
// 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 {
Expand Down
Loading