From ff1b43077ee836d6dc37159c47742b72ecc10d6c Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 19 May 2026 10:37:35 -0400 Subject: [PATCH] feat: allow apply to skip bootstrap --- cmd/wfctl/dsl-reference-embedded.md | 4 ++ cmd/wfctl/infra.go | 4 +- cmd/wfctl/infra_apply_plan_test.go | 74 +++++++++++++++++++++++++++++ docs/WFCTL.md | 2 + 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/cmd/wfctl/dsl-reference-embedded.md b/cmd/wfctl/dsl-reference-embedded.md index 2f120349..874b89a9 100644 --- a/cmd/wfctl/dsl-reference-embedded.md +++ b/cmd/wfctl/dsl-reference-embedded.md @@ -1456,6 +1456,10 @@ wfctl infra security-check --plan plan.json --strict-cidr wfctl infra apply -c infra.yaml --plan plan.json --auto-approve ``` +Use `wfctl infra apply --skip-bootstrap` only for scoped/operator applies where +the required secrets and state backend already exist and bootstrap would mutate +unrelated secret material. + Example output when a finding is detected: ``` diff --git a/cmd/wfctl/infra.go b/cmd/wfctl/infra.go index c1582772..ea085ab8 100644 --- a/cmd/wfctl/infra.go +++ b/cmd/wfctl/infra.go @@ -1222,6 +1222,8 @@ func runInfraApply(args []string) error { "Refresh per-field Outputs from cloud truth before applying (recommended pair with --refresh for cutover-style operations)") var skipRefreshFlag bool fs.BoolVar(&skipRefreshFlag, "skip-refresh", false, "Skip the WFCTL_REFRESH_OUTPUTS pre-step refresh even if the env var is set (does NOT cancel explicit --refresh-outputs)") + var skipBootstrapFlag bool + fs.BoolVar(&skipBootstrapFlag, "skip-bootstrap", false, "Skip auto-bootstrap before apply; use only when required secrets/state already exist") 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)") @@ -1306,7 +1308,7 @@ func runInfraApply(args []string) error { if err != nil { return fmt.Errorf("parse infra config: %w", err) } - autoBootstrap := infraCfg == nil || infraCfg.AutoBootstrap == nil || *infraCfg.AutoBootstrap + autoBootstrap := !skipBootstrapFlag && (infraCfg == nil || infraCfg.AutoBootstrap == nil || *infraCfg.AutoBootstrap) if autoBootstrap { fmt.Println("Running bootstrap before apply...") bootstrapArgs := []string{"--config", cfgFile} diff --git a/cmd/wfctl/infra_apply_plan_test.go b/cmd/wfctl/infra_apply_plan_test.go index 3977bbd6..bf43ec1f 100644 --- a/cmd/wfctl/infra_apply_plan_test.go +++ b/cmd/wfctl/infra_apply_plan_test.go @@ -149,6 +149,80 @@ modules: } } +func TestInfraApplyPlanSkipBootstrap(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "infra.yaml") + if err := os.WriteFile(cfgPath, []byte(` +infra: + auto_bootstrap: true +secrets: + provider: env + generate: + - key: SHOULD_NOT_BOOTSTRAP + type: provider_credential + source: not-a-real-provider + name: should-not-bootstrap +modules: + - name: test-provider + type: iac.provider + config: + provider: fake-cloud + - name: my-dns + type: infra.dns + config: + provider: test-provider + domain: example.com +`), 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: "dns-plan", + DesiredHash: desiredStateHash(specs), + 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) + } + + 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 }() + + applyCalled := false + origApply := applyV2ApplyPlanWithHooksFn + applyV2ApplyPlanWithHooksFn = func(_ context.Context, _ interfaces.IaCProvider, p *interfaces.IaCPlan, _ wfctlhelpers.ApplyPlanHooks) (*interfaces.ApplyResult, error) { + applyCalled = true + if p.ID != "dns-plan" { + t.Fatalf("plan ID = %q, want dns-plan", p.ID) + } + return &interfaces.ApplyResult{PlanID: p.ID}, nil + } + defer func() { applyV2ApplyPlanWithHooksFn = origApply }() + + if err := runInfraApply([]string{"--auto-approve", "--config", cfgPath, "--plan", planPath, "--skip-bootstrap"}); err != nil { + t.Fatalf("runInfraApply: %v", err) + } + if !applyCalled { + t.Fatal("v2 dispatch was not called") + } +} + // TestInfraApplyConsumesPlan_StaleDetection verifies that wfctl infra apply --plan // fails with a descriptive error when the plan's DesiredHash no longer matches the // current desired state (i.e. the config was edited after the plan was generated). diff --git a/docs/WFCTL.md b/docs/WFCTL.md index bcaac80d..2c68fe23 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -1516,6 +1516,7 @@ Reconcile cloud infrastructure to match the desired state declared in the config ``` wfctl infra apply [-c CONFIG] [--env ENV] [--auto-approve] [--plan FILE] [--refresh] [--allow-protected-prune] [--skip-refresh] + [--skip-bootstrap] [--allow-replace=NAME1,NAME2,...] [--dry-run] [--format FMT] ``` @@ -1531,6 +1532,7 @@ wfctl infra apply [-c CONFIG] [--env ENV] [--auto-approve] [--plan FILE] | `--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 | +| `--skip-bootstrap` | `false` | Skip auto-bootstrap before apply when required secrets/state already exist | | `--allow-replace` | `` | Comma-separated list of resource names whose `protected: true` status is overridden for this apply (replace/delete actions only) | | `--plugin-dir` | _(env `WFCTL_PLUGIN_DIR` or `data/plugins`)_ | Override the plugin directory for this invocation. Useful for isolated CI smoke tests. |