Manual Plan Approval¶
With spec.applyPolicy: Manual on a TerraformCluster (or on its TerraformClusterTemplate), the controller plans every change first and applies it only after a person approves that plan. Automatic is the default. applyPolicy is mutable: switching back to Automatic applies whatever was waiting and clears status.plan. The first apply of a new cluster is not gated, since there is no state yet to damage. This page describes the flow; the commands are in Plan Approval.
The flow¶
sequenceDiagram
participant U as Operator
participant C as Controller
participant J as Job and runner
C->>J: plan Job with the plan key
J->>J: validate, plan, fingerprint
J-->>C: counts, resources, plan hash
C->>C: record status.plan, set PlanAwaitingApproval
U->>C: annotate approve-plan with the plan hash
C->>J: apply Job with expect-plan
J->>J: plan again with refresh, fingerprint
alt hash matches
J->>J: apply the saved plan
J-->>C: success
C->>C: clear status.plan, remove approve-plan
else hash differs
J-->>C: stop before applying, new plan
C->>C: update status.plan, set PlanChanged
U->>C: approve the new hash
end - Plan. When an apply is due, the controller starts a plan Job (
status.activeJob.operation: plan) instead. Before the Job, it ensures the plan key Secret exists, and the Job mounts it at/captf/plan-key. The runner runsinit,validateandplan -out, thenshow -jsonwith the output held in memory only. It never writes the plan JSON to disk or to a log; only the binary plan file exists in the working directory. - Record. The runner reports counts, a list of changed resources and the plan hash. The controller records them in
status.plan(below), setsApplyJobSucceededtoUnknown/PlanAwaitingApprovalwith a message that contains the exactkubectl annotatecommand, and emits onePlanReadyevent. - Wait. Nothing applies. The condition is
Unknown, notFalse, so waiting never makesReadyfalse. The controller re-checks at least every ten minutes, and a changed annotation or new inputs trigger it at once. Drift and health checks continue while a plan waits. - Approve. You set
captf.io/approve-plantostatus.plan.planHash. - Apply. The controller starts the apply Job with
--expect-plan=<hash>, the same plan key mount, and the Job annotationcaptf.io/approved-plan, and emitsPlanApproved. The runner plans again, this time including the refresh, and computes the hash of that fresh plan.- If it equals the approved hash, the runner applies exactly the saved plan file.
- If it differs, the runner stops before changing anything and reports the new plan. See When the plan changes.
- Done. After the approved apply succeeds, the controller removes
captf.io/approve-plan, clearsstatus.planand emitsPlanApplied. The removal is its own patch with an optimistic lock, so a newer value that someone wrote in the meantime survives; on a conflict the reconcile requeues and tries again.
What status.plan holds¶
| Field | Meaning |
|---|---|
inputsHash | The inputs hash the plan was made for. A plan is bound to it: new inputs make a new plan |
job | The plan Job |
planHash | The p2: hash to approve (see What the plan hash binds) |
add, change, destroy | Counts of planned resource changes; a replacement counts as both an add and a destroy |
outputChanges | How many outputs change |
resources | Up to 50 entries of <address> (<labels>), sorted by address, never a value |
truncated | Set when more than 50 resources changed |
createdAt | When the controller recorded the plan |
The labels in a resources entry are the action (create, update, delete, replace, read or forget), followed by import and then move where they apply: aws_lb.x (import), aws_instance.b (update, move). Imports and moves are not counted in add, change or destroy, so an entry is the way to see them.
Plan values never reach status, events or logs
They carry counts, addresses and the keyed hash only. The values are in the plan Job’s own log, which the source container prints in human-readable form.
When approval is needed¶
- Everything except the first apply, when
Manualis set: changed inputs, a retry after a failed apply, a drift remediation and a state with no inputs hash. - Output changes. A plan that changes only outputs, or only imports or moves, is not an empty plan and waits for approval.
- Not an empty plan. A plan with no resource, output, import or move change has a fixed hash, and the apply proceeds without approval (no
PlanApprovedevent). It still plans again first, and stops if the plan is no longer empty.
Approving a plan also approves the deletes and replacements it lists
You saw them in status.plan.resources, and the apply runs only that plan. captf.io/approve-destructive-plan is not needed under Manual. lifecycle { prevent_destroy = true } in the module still fails an approved plan.
When the plan changes¶
If the plan the apply Job computes does not hash to the approved value, because the world moved since you reviewed it or because a partial earlier apply changed things, the Job stops before the apply step:
status.lastRun.error.kindisplan-changedand the Job is annotatedcaptf.io/plan-changed;status.planis replaced by the new plan;ApplyJobSucceededbecomesUnknown/PlanChanged, with the new command, and aPlanChangedwarning event is emitted;- the change counts toward neither retry backoff nor the remediation failure cap, and the apply waits for approval of the new hash.
A plan that comes back empty after you approved a non-empty one is a changed plan too.
Retries and stale approvals¶
An approval is consumed only when the approved apply succeeds. After a failed apply the annotation stays, so a retry whose new plan hashes the same runs without another approval, with the usual retry backoff. If the failed apply changed something, the plan differs and needs a new approval.
A leftover approval still approves a later plan with the same hash
This covers, for example, a plan that changed again or inputs that changed before the apply ran. Remove a stale one with kubectl annotate terraformcluster <name> -n <ns> captf.io/approve-plan-. Because the hash binds values, a leftover approval matches only a plan that changes the same attributes to the same values.