diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index d28f02786..3bfb29361 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -984,6 +984,9 @@ func runInfraApply(args []string) error { 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") + var allowReplaceFlag string + fs.StringVar(&allowReplaceFlag, "allow-replace", "", + "Comma-separated list of resource names whose protected: true status is overridden for this apply (replace/delete actions only)") autoApprove := &autoApproveVal showSensitive := showSensitiveVal if err := fs.Parse(args); err != nil { @@ -998,6 +1001,15 @@ func runInfraApply(args []string) error { return fmt.Errorf("--allow-protected-prune requires --refresh") } + // W-6/T6.1: publish the parsed --allow-replace set for the apply + // path's gate (validateAllowReplaceProtected, called from both + // applyWithProviderAndStore and applyPrecomputedPlanWithStore). + // Reset to nil at the top of every invocation so the gate fails + // closed when subsequent runs do not pass the flag — package-level + // state would otherwise leak override authorization across runs. + applyAllowReplaceSet = parseAllowReplaceFlag(allowReplaceFlag) + defer func() { applyAllowReplaceSet = nil }() + cfgFile := configFlag if cfgFile == "" { var err error diff --git a/cmd/wfctl/infra_apply.go b/cmd/wfctl/infra_apply.go index e6ec220ef..ddb013584 100644 --- a/cmd/wfctl/infra_apply.go +++ b/cmd/wfctl/infra_apply.go @@ -58,6 +58,123 @@ var computeInfraPlan = platform.ComputePlan // Same var-seam pattern as computeInfraPlan / resolveIaCProvider. var applyV2ApplyPlanFn = wfctlhelpers.ApplyPlan +// applyAllowReplaceSet is the per-invocation allow-list of resource +// names whose `protected: true` annotation is overridden for this +// apply. Populated by runInfraApply from the --allow-replace= +// flag value via parseAllowReplaceFlag; reset to nil at the top of +// every runInfraApply so the gate fails closed on subsequent +// invocations that do not pass the flag. +// +// Read inside validateAllowReplaceProtected, called from both apply +// dispatch paths (live-diff and --plan). nil/empty set means no +// override — every replace/delete on a protected resource errors +// before dispatch. +var applyAllowReplaceSet map[string]struct{} + +// parseAllowReplaceFlag turns a comma-separated --allow-replace= +// flag value into a name-set. Empty input → nil (the canonical +// "no override" value, indistinguishable from the flag never being +// passed). Whitespace around each name is trimmed so operators can +// copy-paste the design's plan-output format +// (`--allow-replace=name1,name2`) without it falling apart on +// stray spaces. +func parseAllowReplaceFlag(raw string) map[string]struct{} { + if raw == "" { + return nil + } + out := map[string]struct{}{} + for _, name := range strings.Split(raw, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + out[name] = struct{}{} + } + if len(out) == 0 { + return nil + } + return out +} + +// validateAllowReplaceProtected gates dispatch on the per-resource +// `protected: true` annotation. It walks every replace or delete +// action in plan and aggregates ALL blockers (resources protected and +// not in `allow`) into a single error before returning, so the +// operator sees the full set of names in one apply attempt and can +// authorize them with one round-trip via the pre-formatted +// --allow-replace= value. +// +// Format (T6.2): +// +// plan would require destructive action on N protected resource(s): +// (replace) +// (delete) +// ... +// to authorize, re-run with: +// --allow-replace=,,... +// +// Both names and the csv preserve plan-action declaration order so +// the output is deterministic across runs. +// +// `protected: true` is sourced from PlanAction.Resource.Config for +// replace actions (where Resource carries the desired spec) and from +// PlanAction.Current.AppliedConfig for delete actions (where +// platform.differ leaves Resource.Config empty and the protected +// status is preserved on the previously-applied state). +func validateAllowReplaceProtected(plan interfaces.IaCPlan, allow map[string]struct{}) error { + type blocker struct { + name string + action string + } + var blockers []blocker + for i := range plan.Actions { + a := &plan.Actions[i] + if a.Action != "replace" && a.Action != "delete" { + continue + } + if !planActionIsProtected(a) { + continue + } + if _, ok := allow[a.Resource.Name]; ok { + continue + } + blockers = append(blockers, blocker{name: a.Resource.Name, action: a.Action}) + } + if len(blockers) == 0 { + return nil + } + + var b strings.Builder + fmt.Fprintf(&b, "plan would require destructive action on %d protected resource(s):", len(blockers)) + names := make([]string, 0, len(blockers)) + for _, blk := range blockers { + fmt.Fprintf(&b, "\n %s (%s)", blk.name, blk.action) + names = append(names, blk.name) + } + fmt.Fprintf(&b, "\nto authorize, re-run with:\n --allow-replace=%s", strings.Join(names, ",")) + return errors.New(b.String()) +} + +// planActionIsProtected reports whether the action targets a resource +// annotated `protected: true`. Replace actions carry the desired +// Config on Resource; delete actions (built by platform.differ from +// current state) carry the protected flag on Current.AppliedConfig. +// Returning true if either source declares protected covers both +// classes. +func planActionIsProtected(a *interfaces.PlanAction) bool { + if a.Resource.Config != nil { + if p, ok := a.Resource.Config["protected"].(bool); ok && p { + return true + } + } + if a.Current != nil && a.Current.AppliedConfig != nil { + if p, ok := a.Current.AppliedConfig["protected"].(bool); ok && p { + return true + } + } + return false +} + // hasInfraModules reports whether cfgFile contains any modules with the new // infra.* type prefix. Used by runInfraApply to select the dispatch path: // direct IaCProvider path for infra.* configs, pipeline path for legacy @@ -375,6 +492,16 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi return nil } + // W-6/T6.1: gate replace and delete actions on `protected: true` + // resources behind --allow-replace. Without an explicit per-resource + // opt-in, the apply errors before any provider Apply / wfctlhelpers + // dispatch — destructive actions on protected infrastructure must + // be intentional. T6.2 swaps this fail-fast for an aggregated + // multi-blocker report. + if err := validateAllowReplaceProtected(plan, applyAllowReplaceSet); err != nil { + return err + } + // Collect delete-action resource names so we can clean up state afterward. deleteNames := make(map[string]struct{}) for i := range plan.Actions { @@ -398,6 +525,9 @@ func applyWithProviderAndStore(ctx context.Context, provider interfaces.IaCProvi // load time and surfaced via the optional // wfctlhelpers.ComputePlanVersionDeclarer interface. var result *interfaces.ApplyResult + // DispatchVersionFor centralises the type-assertion + default; pass the + // raw provider rather than re-asserting ComputePlanVersionDeclarer at the + // call site (per dispatch.go contract). if wfctlhelpers.DispatchVersionFor(provider) == wfctlhelpers.DispatchVersionV2 { result, err = applyV2ApplyPlanFn(ctx, provider, &plan) // printDriftReportIfAny was added unwired in W-3a/T3.1.5; the @@ -851,6 +981,13 @@ func applyPrecomputedPlanWithStore(ctx context.Context, plan interfaces.IaCPlan, return nil } + // W-6/T6.1: same protected-resource gate as the live-diff path. The + // --plan path skips ComputePlan entirely but the safety guarantee + // must hold regardless of how the plan was produced. + if err := validateAllowReplaceProtected(plan, applyAllowReplaceSet); err != nil { + return err + } + // Collect delete-action resource names for post-apply state cleanup. deleteNames := make(map[string]struct{}) for i := range plan.Actions { diff --git a/cmd/wfctl/infra_apply_allow_replace_test.go b/cmd/wfctl/infra_apply_allow_replace_test.go new file mode 100644 index 000000000..443c5d3f5 --- /dev/null +++ b/cmd/wfctl/infra_apply_allow_replace_test.go @@ -0,0 +1,305 @@ +package main + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/iac/iactest" + "github.com/GoCodeAlone/workflow/interfaces" +) + +// W-6 / T6.1: --allow-replace flag on apply. +// +// Without the flag, a Replace (or Delete) action targeting a resource +// annotated `protected: true` must error before dispatch. With the flag +// listing the resource name, the protection is bypassed and the apply +// proceeds to the provider dispatch step. + +// applyAllowReplaceSet is the per-invocation allow-list of resource +// names whose protected: true status is overridden for this apply. +// Set by runInfraApply from --allow-replace=; reset +// to nil at the top of every invocation. Tests set it directly to +// drive the gate. +// +// Read-only inside the apply path (validateAllowReplaceProtected); the +// pkg-level pattern matches computeInfraPlan / applyV2ApplyPlanFn. +// +// The test interface promises: +// - validateAllowReplaceProtected(plan, allow) returns nil when no +// replace/delete action targets a protected resource. +// - returns an error matching the design literal when a protected +// resource is replaced/deleted without being listed in `allow`. + +// TestValidateAllowReplaceProtected_NoProtectedActions_NoError verifies +// the gate is a no-op when the plan contains no replace/delete actions +// on protected resources. +func TestValidateAllowReplaceProtected_NoProtectedActions_NoError(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "create", Resource: interfaces.ResourceSpec{Name: "vpc-1", Type: "infra.vpc"}}, + {Action: "update", Resource: interfaces.ResourceSpec{Name: "vpc-2", Type: "infra.vpc", Config: map[string]any{"protected": true}}}, + }, + } + if err := validateAllowReplaceProtected(plan, nil); err != nil { + t.Fatalf("expected no error for non-replace/delete actions on protected: got %v", err) + } +} + +// TestValidateAllowReplaceProtected_ReplaceProtected_WithoutAllowList_Errors +// is the canonical T6.1 spec case. The replace action targets a +// protected resource and the allow-list is empty — the gate must +// return an error mentioning the resource name and the override flag. +func TestValidateAllowReplaceProtected_ReplaceProtected_WithoutAllowList_Errors(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + { + Action: "replace", + Resource: interfaces.ResourceSpec{ + Name: "prod-db", + Type: "infra.database", + Config: map[string]any{"protected": true}, + }, + }, + }, + } + err := validateAllowReplaceProtected(plan, nil) + if err == nil { + t.Fatal("expected error for replace on protected resource without --allow-replace") + } + // T6.2 superseded T6.1's single-line literal with an aggregated + // multi-blocker format that always emits a copy-paste flag value. + // Assert the operator-facing essentials: resource name surfaces + + // copy-paste flag is pre-formatted with the blocked name. + msg := err.Error() + if !strings.Contains(msg, "prod-db") { + t.Errorf("expected error to mention resource name; got %q", msg) + } + if !strings.Contains(msg, "--allow-replace=prod-db") { + t.Errorf("expected error to include copy-paste flag --allow-replace=prod-db; got %q", msg) + } +} + +// TestValidateAllowReplaceProtected_DeleteProtected_WithoutAllowList_Errors +// covers the design's "and would be %sd" for delete actions. ComputePlan +// emits delete actions with empty Resource.Config; the protected flag is +// recovered from Current.AppliedConfig. +func TestValidateAllowReplaceProtected_DeleteProtected_WithoutAllowList_Errors(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + { + Action: "delete", + Resource: interfaces.ResourceSpec{ + Name: "prod-db", + Type: "infra.database", + }, + Current: &interfaces.ResourceState{ + Name: "prod-db", + Type: "infra.database", + AppliedConfig: map[string]any{"protected": true}, + }, + }, + }, + } + err := validateAllowReplaceProtected(plan, nil) + if err == nil { + t.Fatal("expected error for delete on protected resource without --allow-replace") + } + // T6.2 batch format: assert the essentials — name surfaces + the + // copy-paste flag is pre-formatted with the blocked name. The + // "delete" verb is preserved in the per-line listing (covered by + // the mixed-replace-and-delete test in T6.2 set). + msg := err.Error() + if !strings.Contains(msg, "prod-db") { + t.Errorf("expected error to mention resource name; got %q", msg) + } + if !strings.Contains(msg, "--allow-replace=prod-db") { + t.Errorf("expected error to include copy-paste flag --allow-replace=prod-db; got %q", msg) + } +} + +// TestValidateAllowReplaceProtected_ReplaceProtected_InAllowList_Allowed +// is the override path: when the resource name is in the allow set the +// gate returns nil so the apply can proceed. +func TestValidateAllowReplaceProtected_ReplaceProtected_InAllowList_Allowed(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + { + Action: "replace", + Resource: interfaces.ResourceSpec{ + Name: "prod-db", + Type: "infra.database", + Config: map[string]any{"protected": true}, + }, + }, + }, + } + allow := map[string]struct{}{"prod-db": {}} + if err := validateAllowReplaceProtected(plan, allow); err != nil { + t.Errorf("expected gate to pass when resource is in allow list: got %v", err) + } +} + +// TestValidateAllowReplaceProtected_NonProtectedReplace_NoError verifies +// replace on a non-protected resource is unaffected by the gate. +func TestValidateAllowReplaceProtected_NonProtectedReplace_NoError(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + { + Action: "replace", + Resource: interfaces.ResourceSpec{Name: "dev-vpc", Type: "infra.vpc"}, + }, + }, + } + if err := validateAllowReplaceProtected(plan, nil); err != nil { + t.Errorf("expected no error for non-protected replace: got %v", err) + } +} + +// TestParseAllowReplaceFlag_EmptyAndCommaSeparated covers the parse +// helper that turns the --allow-replace= flag value into a set. +// Empty input → empty set (or nil); comma-separated names → set with +// each name. Whitespace around names is trimmed so operators copy-paste +// from plan output without surprises. +func TestParseAllowReplaceFlag_EmptyAndCommaSeparated(t *testing.T) { + cases := []struct { + name string + raw string + want []string + notWant []string + }{ + {name: "empty", raw: "", notWant: []string{"any"}}, + {name: "single", raw: "vpc-1", want: []string{"vpc-1"}, notWant: []string{"vpc-2"}}, + {name: "csv", raw: "vpc-1,vpc-2,db-1", want: []string{"vpc-1", "vpc-2", "db-1"}, notWant: []string{"vpc-3"}}, + {name: "trim-spaces", raw: " vpc-1 , vpc-2 ", want: []string{"vpc-1", "vpc-2"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + set := parseAllowReplaceFlag(tc.raw) + for _, name := range tc.want { + if _, ok := set[name]; !ok { + t.Errorf("expected %q in set, got %v", name, set) + } + } + for _, name := range tc.notWant { + if _, ok := set[name]; ok { + t.Errorf("did not expect %q in set, got %v", name, set) + } + } + }) + } +} + +// TestApplyWithProviderAndStore_ProtectedReplace_WithoutAllowReplace_Errors +// integration-checks that the gate fires inside the live-diff apply +// path (applyWithProviderAndStore). The fake ComputePlan emits a +// replace on a protected resource; with no allow set, the apply +// returns the expected error before dispatching to the provider. +func TestApplyWithProviderAndStore_ProtectedReplace_WithoutAllowReplace_Errors(t *testing.T) { + provider := &iactest.NoopProvider{ProviderName: "allow-replace-stub"} + + origCompute := computeInfraPlan + computeInfraPlan = func(_ context.Context, _ interfaces.IaCProvider, specs []interfaces.ResourceSpec, _ []interfaces.ResourceState) (interfaces.IaCPlan, error) { + actions := make([]interfaces.PlanAction, 0, len(specs)) + for _, s := range specs { + actions = append(actions, interfaces.PlanAction{Action: "replace", Resource: s}) + } + return interfaces.IaCPlan{Actions: actions}, nil + } + t.Cleanup(func() { computeInfraPlan = origCompute }) + + // Reset package-level allow set so test order doesn't matter. + origAllow := applyAllowReplaceSet + applyAllowReplaceSet = nil + t.Cleanup(func() { applyAllowReplaceSet = origAllow }) + + specs := []interfaces.ResourceSpec{ + {Name: "prod-db", Type: "infra.database", Config: map[string]any{"protected": true}}, + } + + var w bytes.Buffer + err := applyWithProviderAndStore(context.Background(), provider, "stub", specs, nil, nil, &w, "test") + if err == nil { + t.Fatal("expected gate error before dispatch") + } + if !strings.Contains(err.Error(), "prod-db") || !strings.Contains(err.Error(), "--allow-replace=prod-db") { + t.Errorf("error missing expected fragments: %v", err) + } +} + +// TestApplyWithProviderAndStore_ProtectedReplace_WithAllowReplace_Proceeds +// confirms the override path: with applyAllowReplaceSet listing the +// protected resource, the gate is bypassed and the apply continues +// past the gate. We route through the v2 dispatch (sentinel return +// via applyV2ApplyPlanFn) so a sentinel error proves the call site +// reached dispatch — i.e. the gate did not short-circuit. +func TestApplyWithProviderAndStore_ProtectedReplace_WithAllowReplace_Proceeds(t *testing.T) { + provider := &iactest.NoopProvider{ProviderName: "allow-replace-stub", DispatchVersion: "v2"} + + origCompute := computeInfraPlan + computeInfraPlan = func(_ context.Context, _ interfaces.IaCProvider, specs []interfaces.ResourceSpec, _ []interfaces.ResourceState) (interfaces.IaCPlan, error) { + actions := make([]interfaces.PlanAction, 0, len(specs)) + for _, s := range specs { + actions = append(actions, interfaces.PlanAction{Action: "replace", Resource: s}) + } + return interfaces.IaCPlan{Actions: actions}, nil + } + t.Cleanup(func() { computeInfraPlan = origCompute }) + + dispatched := errors.New("v2 ApplyPlan reached") + origApply := applyV2ApplyPlanFn + applyV2ApplyPlanFn = func(_ context.Context, _ interfaces.IaCProvider, _ *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { + return nil, dispatched + } + t.Cleanup(func() { applyV2ApplyPlanFn = origApply }) + + origAllow := applyAllowReplaceSet + applyAllowReplaceSet = map[string]struct{}{"prod-db": {}} + t.Cleanup(func() { applyAllowReplaceSet = origAllow }) + + specs := []interfaces.ResourceSpec{ + {Name: "prod-db", Type: "infra.database", Config: map[string]any{"protected": true}}, + } + + var w bytes.Buffer + err := applyWithProviderAndStore(context.Background(), provider, "stub", specs, nil, nil, &w, "test") + if err == nil || !errors.Is(err, dispatched) { + t.Fatalf("expected gate to allow apply through to dispatch (sentinel %v); got %v", dispatched, err) + } +} + +// TestApplyPrecomputedPlanWithStore_ProtectedReplace_WithoutAllowReplace_Errors +// verifies the gate also fires through the --plan path +// (applyPrecomputedPlanWithStore). The protected guarantee must hold +// regardless of whether the operator runs apply or apply --plan. +func TestApplyPrecomputedPlanWithStore_ProtectedReplace_WithoutAllowReplace_Errors(t *testing.T) { + provider := &iactest.NoopProvider{ProviderName: "allow-replace-stub"} + + origAllow := applyAllowReplaceSet + applyAllowReplaceSet = nil + t.Cleanup(func() { applyAllowReplaceSet = origAllow }) + + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + { + Action: "replace", + Resource: interfaces.ResourceSpec{ + Name: "prod-db", + Type: "infra.database", + Config: map[string]any{"protected": true}, + }, + }, + }, + } + + var w bytes.Buffer + err := applyPrecomputedPlanWithStore(context.Background(), plan, provider, "stub", nil, &w, "test") + if err == nil { + t.Fatal("expected gate error before dispatch via precomputed-plan path") + } + if !strings.Contains(err.Error(), "prod-db") || !strings.Contains(err.Error(), "--allow-replace=prod-db") { + t.Errorf("precomputed-plan gate error missing expected fragments: %v", err) + } +} diff --git a/cmd/wfctl/infra_apply_batch_blockers_test.go b/cmd/wfctl/infra_apply_batch_blockers_test.go new file mode 100644 index 000000000..58a83b65c --- /dev/null +++ b/cmd/wfctl/infra_apply_batch_blockers_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// W-6 / T6.2: validateAllowReplaceProtected emits ALL blockers in one +// pass, with a copy-paste-ready --allow-replace= flag value, so +// operators don't have to discover blockers one by one across N apply +// runs. +// +// Per plan T6.2: +// "Plan with 5 protected resources requiring replace → error message +// lists ALL 5 + pre-formatted --allow-replace=name1,name2,name3, +// name4,name5 for copy-paste." + +// TestValidateAllowReplaceProtected_FiveProtected_ReportsAll verifies +// the canonical plan-spec example: 5 protected replaces, none in the +// allow list, error names every resource AND emits the full +// copy-paste flag value with the names in plan-action order. +func TestValidateAllowReplaceProtected_FiveProtected_ReportsAll(t *testing.T) { + names := []string{"vpc-1", "vpc-2", "db-1", "cache-1", "redis-1"} + actions := make([]interfaces.PlanAction, 0, len(names)) + for _, n := range names { + actions = append(actions, interfaces.PlanAction{ + Action: "replace", + Resource: interfaces.ResourceSpec{ + Name: n, + Type: "infra.vpc", + Config: map[string]any{"protected": true}, + }, + }) + } + plan := interfaces.IaCPlan{Actions: actions} + + err := validateAllowReplaceProtected(plan, nil) + if err == nil { + t.Fatal("expected error reporting all 5 protected blockers") + } + msg := err.Error() + for _, n := range names { + if !strings.Contains(msg, n) { + t.Errorf("error message missing blocker %q\n got: %s", n, msg) + } + } + wantFlag := "--allow-replace=vpc-1,vpc-2,db-1,cache-1,redis-1" + if !strings.Contains(msg, wantFlag) { + t.Errorf("error message missing copy-paste flag value %q\n got: %s", wantFlag, msg) + } +} + +// TestValidateAllowReplaceProtected_PartialAllowList_ReportsOnlyRemainder +// proves the allow-set is honored at batch-aggregation time: with +// some names allowed, the error lists ONLY the still-blocked names +// and the copy-paste flag includes only those. +func TestValidateAllowReplaceProtected_PartialAllowList_ReportsOnlyRemainder(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + {Action: "replace", Resource: interfaces.ResourceSpec{Name: "vpc-1", Type: "infra.vpc", Config: map[string]any{"protected": true}}}, + {Action: "replace", Resource: interfaces.ResourceSpec{Name: "vpc-2", Type: "infra.vpc", Config: map[string]any{"protected": true}}}, + {Action: "replace", Resource: interfaces.ResourceSpec{Name: "db-1", Type: "infra.database", Config: map[string]any{"protected": true}}}, + }, + } + allow := map[string]struct{}{"vpc-1": {}} + + err := validateAllowReplaceProtected(plan, allow) + if err == nil { + t.Fatal("expected error for the still-blocked resources") + } + msg := err.Error() + if strings.Contains(msg, "vpc-1") { + t.Errorf("error should not mention already-allowed resource vpc-1\n got: %s", msg) + } + for _, n := range []string{"vpc-2", "db-1"} { + if !strings.Contains(msg, n) { + t.Errorf("error missing still-blocked resource %q\n got: %s", n, msg) + } + } + if !strings.Contains(msg, "--allow-replace=vpc-2,db-1") { + t.Errorf("error missing copy-paste flag for remainder %q\n got: %s", "--allow-replace=vpc-2,db-1", msg) + } +} + +// TestValidateAllowReplaceProtected_MixedReplaceAndDelete_ReportsBoth +// covers T6.1's gate scope: replace AND delete actions both contribute +// blockers when their resource is protected. The aggregated error must +// surface both, in plan-action order, and the copy-paste flag must +// list both names. +func TestValidateAllowReplaceProtected_MixedReplaceAndDelete_ReportsBoth(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + { + Action: "replace", + Resource: interfaces.ResourceSpec{ + Name: "prod-vpc", + Type: "infra.vpc", + Config: map[string]any{"protected": true}, + }, + }, + { + Action: "delete", + Resource: interfaces.ResourceSpec{ + Name: "prod-db", + Type: "infra.database", + }, + Current: &interfaces.ResourceState{ + Name: "prod-db", + Type: "infra.database", + AppliedConfig: map[string]any{"protected": true}, + }, + }, + }, + } + + err := validateAllowReplaceProtected(plan, nil) + if err == nil { + t.Fatal("expected error reporting both replace and delete blockers") + } + msg := err.Error() + for _, n := range []string{"prod-vpc", "prod-db"} { + if !strings.Contains(msg, n) { + t.Errorf("error missing %q\n got: %s", n, msg) + } + } + if !strings.Contains(msg, "--allow-replace=prod-vpc,prod-db") { + t.Errorf("error missing copy-paste flag with both names\n got: %s", msg) + } +} + +// TestValidateAllowReplaceProtected_SingleBlocker_StillBatchFormat +// guards regression: a single blocker should still emit the +// aggregated batch format (not the legacy single-line format), so the +// operator-facing UX is consistent regardless of blocker count. +// The copy-paste flag is the most important artifact for automation. +func TestValidateAllowReplaceProtected_SingleBlocker_StillBatchFormat(t *testing.T) { + plan := interfaces.IaCPlan{ + Actions: []interfaces.PlanAction{ + { + Action: "replace", + Resource: interfaces.ResourceSpec{ + Name: "prod-db", + Type: "infra.database", + Config: map[string]any{"protected": true}, + }, + }, + }, + } + err := validateAllowReplaceProtected(plan, nil) + if err == nil { + t.Fatal("expected error for single protected blocker") + } + msg := err.Error() + if !strings.Contains(msg, "prod-db") { + t.Errorf("error missing resource name: %s", msg) + } + if !strings.Contains(msg, "--allow-replace=prod-db") { + t.Errorf("error missing copy-paste flag: %s", msg) + } +} + +// TestValidateAllowReplaceProtected_OrderPreservesPlanActionOrder +// pins the deterministic ordering of names in both the listing and +// the copy-paste flag value: plan-action declaration order. Stable +// ordering matters for diff-stable test golden files and for +// operator coordination ("the order I see in plan output is the +// order I'll see in the error"). +func TestValidateAllowReplaceProtected_OrderPreservesPlanActionOrder(t *testing.T) { + // Intentionally non-alphabetic order so the test fails if the + // implementation sorts names instead of preserving order. + planOrder := []string{"zeta-vpc", "alpha-db", "mu-cache"} + actions := make([]interfaces.PlanAction, 0, len(planOrder)) + for _, n := range planOrder { + actions = append(actions, interfaces.PlanAction{ + Action: "replace", + Resource: interfaces.ResourceSpec{ + Name: n, + Type: "infra.vpc", + Config: map[string]any{"protected": true}, + }, + }) + } + err := validateAllowReplaceProtected(interfaces.IaCPlan{Actions: actions}, nil) + if err == nil { + t.Fatal("expected error") + } + want := "--allow-replace=zeta-vpc,alpha-db,mu-cache" + if !strings.Contains(err.Error(), want) { + t.Errorf("expected plan-action-ordered csv %q\n got: %s", want, err.Error()) + } +} diff --git a/docs/WFCTL.md b/docs/WFCTL.md index ffb432bca..af6e79303 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -1218,6 +1218,65 @@ 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 apply` + +Reconcile cloud infrastructure to match the desired state declared in the config. Computes a diff plan via each `iac.provider` and dispatches creates/updates/replaces/deletes through the loaded provider plugin. State is persisted after every successful action so the next run sees the cloud-truth. + +``` +wfctl infra apply [-c CONFIG] [--env ENV] [--auto-approve] [--plan FILE] + [--refresh] [--allow-protected-prune] [--skip-refresh] + [--allow-replace=NAME1,NAME2,...] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `-c`, `--config` | _(auto-detected)_ | Config file (searches `infra.yaml`, `config/infra.yaml`) | +| `-y`, `--auto-approve` | `false` | Skip the confirmation prompt | +| `-S`, `--show-sensitive` | `false` | Show sensitive values in plaintext | +| `--env` | `` | Environment name (resolves per-module `environments:` overrides) | +| `--plan` | `` | Apply from a pre-emitted `plan.json` (skips `ComputePlan`) | +| `--refresh` | `false` | Detect drift and prune ghost-in-state entries before applying | +| `--allow-protected-prune` | `false` | Allow pruning state entries for resources marked `protected: true` (requires `--refresh`) | +| `--skip-refresh` | `false` | Skip the `WFCTL_REFRESH_OUTPUTS` pre-step refresh even if the env var is set | +| `--allow-replace` | `` | Comma-separated list of resource names whose `protected: true` status is overridden for this apply (replace/delete actions only) | + +**Protected-resource gate:** + +Resources annotated `protected: true` cannot be replaced or deleted by `infra apply` without an explicit per-resource opt-in. When a plan would replace or delete a protected resource, `wfctl` aborts before any provider dispatch and prints the full set of blockers in one pass with a copy-paste-ready flag value: + +``` +plan would require destructive action on 3 protected resource(s): + coredump-staging-vpc (replace) + coredump-staging-pg-data (replace) + coredump-staging-pg (replace) +to authorize, re-run with: + --allow-replace=coredump-staging-vpc,coredump-staging-pg-data,coredump-staging-pg +``` + +The gate fires on both dispatch paths — live diff (`apply` without `--plan`) and precomputed plan (`apply --plan plan.json`) — so the safety guarantee holds regardless of plan provenance. The blocker listing and the csv preserve plan-action declaration order so output is deterministic across runs. + +To authorize, re-run with the printed flag value. Names not in the list keep their protection; the override is per-invocation and never persisted. + +| Knob | Effect | +|------|--------| +| `--allow-replace=name1,name2` | Authorize replace/delete on the listed resources for this apply only. | +| `--allow-protected-prune` (requires `--refresh`) | Older flag — authorizes pruning state entries for **all** protected resources during the refresh phase. Recommended only for state cleanup; for production use prefer `--allow-replace` (intent-explicit per resource). | + +**Examples:** + +```bash +# Standard apply. +wfctl infra apply --auto-approve -c infra.yaml --env staging + +# Apply from a pre-emitted plan. +wfctl infra plan -c infra.yaml --env staging -o plan.json +wfctl infra apply --auto-approve -c infra.yaml --env staging --plan plan.json + +# Authorize a Replace cascade on protected resources. +wfctl infra apply --auto-approve -c infra.yaml --env prod \ + --allow-replace=coredump-prod-vpc,coredump-prod-pg +``` + #### `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.