TerraformPlan¶
A TerraformPlan is one plan that the manager made for a TerraformCluster or a TerraformMachinePool and that waits for an approval before it is applied. The manager creates it and owns it; you approve it by setting spec.approved. The name is derived from the Job that made the plan and the plan hash, so it is deterministic per Job: a recurring identical plan, such as the same drift planned again later, is a new object. A target names its live plan in status.pendingPlanRef.name. Because it is an object of its own, an approval can be listed, selected, watched, audited and automated like any other Kubernetes object, and it moves with its target through clusterctl move.
TerraformPlan is a public integration API. Its field names, its labels, its phases and its conditions are frozen for v1alpha1: a dashboard, a policy engine or a pipeline may depend on them. The plan itself never carries a value, only addresses, actions and counts.
| API version | infrastructure.cluster.x-k8s.io/v1alpha1 |
| Kind | TerraformPlan |
| Scope | Namespaced, in the namespace of its target |
| Created by | The manager, named <target name>-<10 hex chars>. You never write the plan fields |
| Owned by | Its target (spec.targetRef), through a controller owner reference |
| Finalizer | none |
| Short names | tfplan |
| Categories | cluster-api |
| Status subresource | yes |
Example¶
A plan for a TerraformCluster that replaces one resource, as kubectl get -o yaml shows it before anyone approves it:
apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
kind: TerraformPlan
metadata:
name: demo-3f9a1c07be
namespace: default
labels:
cluster.x-k8s.io/cluster-name: demo
captf.io/destructive: "true"
captf.io/plan-phase: Pending
captf.io/plan-reason: Destructive
spec:
targetRef:
kind: TerraformCluster
name: demo
planHash: "p2:9c1e0d4a6b7f2e3d5a8c1b0f4e6d7a9c2b3e5f8a1d4c6b7e0f2a3d5c8b1e4f6a"
inputsHash: "h2:51ab0c3d7e9f2a4b6c8d0e1f3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b"
reason: Destructive
summary:
create: 1
update: 2
replace: 1
delete: 0
resources:
- aws_instance.control_plane (replace)
- aws_lb.api (update)
- aws_route53_record.api (update)
- aws_security_group_rule.api (create)
status:
phase: Pending
observedGeneration: 1
To approve it, set approved and your own username in approvedBy:
kubectl patch terraformplan demo-3f9a1c07be --type merge \
-p "{\"spec\":{\"approved\":true,\"approvedBy\":\"$(kubectl auth whoami -o jsonpath='{.status.userInfo.username}')\"}}"
Spec¶
The manager writes every field except approved and approvedBy when it creates the object, and none of them ever changes afterwards.
| Field | Type | Description |
|---|---|---|
spec.targetRef | object | The object the plan is for, in the plan’s namespace. Required. Immutable. |
spec.targetRef.kind | string | The kind of the target. Required. Allowed values: TerraformCluster, TerraformMachinePool. |
spec.targetRef.name | string | The name of the target. Required. Range: 1 to 253 characters. |
spec.planHash | string | The fingerprint of the plan’s changes. An approval covers exactly the plan with this hash: the apply plans again and runs only if the hash is the same. Required. Immutable. Range: 1 to 128 characters. |
spec.inputsHash | string | The hash of the inputs the plan was made for. Required. Immutable. Range: 1 to 128 characters. |
spec.reason | string | Why the plan waits for an approval. Required. Immutable. Allowed values: see Reasons. |
spec.summary | object | The summary of the plan: counts and the changed resources, never a value. Required. Immutable. Must set at least one property. |
spec.summary.create | integer | Resources the plan creates. Range: 0 or more. |
spec.summary.update | integer | Resources the plan updates in place. Range: 0 or more. |
spec.summary.replace | integer | Resources the plan replaces: deletes and creates again. A replacement counts here only. Range: 0 or more. |
spec.summary.delete | integer | Resources the plan deletes, not counting replacements. Range: 0 or more. |
spec.summary.import | integer | Resources the plan imports into the state. Range: 0 or more. |
spec.summary.move | integer | Resources a moved block moves to a new address. Range: 0 or more. |
spec.summary.forget | integer | Resources the plan removes from the state without destroying them. Range: 0 or more. |
spec.summary.outputChanges | integer | Root module outputs the plan changes. An output change alone needs approval, because cluster exports feed every machine and pool module. Range: 0 or more. |
spec.summary.resources | array of strings | <address> (<labels>) of each changed resource, sorted by address. Range: at most 50 items, each 1 to 600 characters. The labels are the action (create, update, delete, replace, read or forget), or import or move for an otherwise unchanged resource, comma-separated: aws_instance.a (import), aws_instance.b (update, move). |
spec.summary.truncated | boolean | true when spec.summary.resources lists fewer resources than the plan changes. |
spec.approved | boolean | Approves the plan. Optional. It can only change from unset or false to true, never back, and only while the plan is live (Phases). |
spec.approvedBy | string | The user that approved. Optional, and required when approved is true. It must equal the username of the request that sets approved (Who can approve). Range: 1 to 512 characters. |
Reasons¶
spec.reason | The plan waits because |
|---|---|
Manual | The TerraformCluster has spec.applyPolicy: Manual, so every change waits. |
Destructive | The plan deletes or replaces resources, and the target applies automatically otherwise. |
ExportsChange | A TerraformMachinePool applies a change of its cluster’s exports. |
Status¶
The manager sets status; you do not write it. status has at least one property when present.
| Field | Type | Description |
|---|---|---|
status.conditions | array of Condition | The plan’s conditions: Ready and Approved. Range: up to 32 items. Keyed by type. |
status.conditions[].type | string | The condition type: Ready or Approved. |
status.conditions[].status | string | True, False or Unknown. |
status.conditions[].reason | string | A machine-readable reason in CamelCase. |
status.conditions[].message | string | A human-readable detail. |
status.conditions[].lastTransitionTime | time | When status last changed. |
status.conditions[].observedGeneration | integer | The metadata.generation the condition was computed from. |
status.phase | string | Where the plan is in its life. Allowed values: see Phases. |
status.observedGeneration | integer | The metadata.generation the status was computed for. Range: 1 or more. |
Phases¶
status.phase | Meaning | Live |
|---|---|---|
Pending | The plan waits for an approval. | yes |
Approved | The plan was approved and its apply has not finished. | yes |
Applied | The plan was approved and its apply succeeded. | no |
Superseded | A newer plan of the target replaced it, or it became moot, before it was applied. | no |
Failed | The plan was approved, but its apply planned other changes and stopped. | no |
A target has at most one live plan: creating a plan supersedes the previous live one. A plan becomes moot when the target’s inputs no longer hash to spec.inputsHash, when no apply is due any more, when the applyPolicy changed, or, for a pool, when the change of the cluster’s exports was withdrawn or replaced. A failed step of the apply, or a deadline, keeps the plan Approved: the apply is retried with the same expected plan.
Applied, Superseded and Failed are terminal: a terminal plan can no longer be approved. The manager keeps the 10 newest finished plans of each target and deletes older ones. It never prunes a live plan, and prunes nothing while the target is paused or deleting.
Conditions¶
| Type | Status | Reason | When |
|---|---|---|---|
Ready | True | Pending | The plan is live and waits for an approval. |
Ready | True | Approved | The plan is approved and its apply has not finished. |
Ready | True | Applied | The apply succeeded. |
Ready | False | Superseded | The plan was superseded. |
Ready | False | Failed | The approved apply planned other changes. |
Approved | True | Approved | spec.approved is true (Approved, Applied or Failed). |
Approved | False | Pending | The plan is live and not approved. |
Approved | False | NotApproved | The plan is finished and was never approved. |
Approved | False | ApprovalIgnored | The plan was approved, then superseded before it was applied. |
An approval can land just before the plan is superseded. The webhook refuses an approval only when the plan’s label is already terminal, so such an approval is ignored: Approved is False with the reason ApprovalIgnored, and the PlanSuperseded warning on the target names the current plan.
observedGeneration on the object and on each condition tells an integration whether the status already reflects the latest spec.
Labels¶
The manager puts these labels on every plan, so you can select plans without reading their specs. Their keys and values are frozen for v1alpha1. Every key is also on Annotations, Labels and Finalizers.
| Label | Value |
|---|---|
cluster.x-k8s.io/cluster-name | The name of the Cluster the target belongs to. |
captf.io/destructive | true when the plan replaces or deletes a resource (spec.summary.replace plus spec.summary.delete is above 0), otherwise false. |
captf.io/plan-phase | The same value as status.phase. It lives on the metadata because clusterctl move drops status: a moved plan keeps its phase, and a finished plan cannot be approved again. Only the manager may change it. |
captf.io/plan-reason | The same value as spec.reason. |
# Every plan waiting for an approval that deletes or replaces something
kubectl get terraformplans -A \
-l captf.io/plan-phase=Pending,captf.io/destructive=true
Printer columns¶
kubectl get terraformplans (or kubectl get tfplan) shows:
| Column | Source |
|---|---|
Target | .spec.targetRef.name |
Reason | .spec.reason |
Phase | .status.phase |
Create | .spec.summary.create |
Update | .spec.summary.update |
Replace | .spec.summary.replace |
Delete | .spec.summary.delete |
Approved | .spec.approved |
Age | .metadata.creationTimestamp |
Who can approve¶
An approval is a write to a TerraformPlan, so it is Kubernetes RBAC on terraformplans:
- Approvers need
get,listandwatchto find the plan, andpatch(orupdate) to approve it. They need nothing on the target. createequals approve. The admission webhook accepts a plan created withapproved: trueandapprovedByset to the creator, becauseclusterctl movecreates plans again on the target cluster as the mover. Whoever may createterraformplanscan therefore create an approved plan. Grantcreateonly to the manager’s ServiceAccount and to the identity that runsclusterctl move.approvedByis verified. The webhook requiresspec.approvedByto equal the username of the request that setsspec.approvedtotrue, so the field names who approved and not who claims to have. CAPTF has no mutating webhooks, so you write the field yourself: the command above fills it fromkubectl auth whoami.
See Who can approve for example Roles and a ValidatingAdmissionPolicy for tiered auto-approval.
Validation¶
The CRD schema, CEL rules in the CRD and the validating admission webhook enforce these rules. The webhook runs on create and update, and a request fails if the webhook is unreachable. The CEL rules hold even then: spec.targetRef, spec.planHash, spec.inputsHash, spec.reason and spec.summary are immutable, approvedBy is required exactly when approved is true, and an approval can be neither withdrawn nor changed; a violation is refused with 422 Invalid.
Schema rules:
spec.targetRef,spec.planHash,spec.inputsHash,spec.reasonandspec.summaryare required, with the allowed values and ranges above.spec.summaryandstatusmust set at least one property when present, andstatus.conditionshas at most 32 items.
Webhook rules, on create:
spec.approvedByis required whenspec.approvedistrue, and refused when it is not.- Unless the manager creates the plan,
spec.approvedBymust equal the creating user whenspec.approvedistrue. A plan whosecaptf.io/plan-phaselabel is terminal (Applied,SupersededorFailed) is accepted with anyapprovedBy, soclusterctl movestill works for finished plans. - A creator other than the manager may set the
captf.io/plan-phaselabel only toPending, or toApprovedon an approved plan.
Webhook rules, on update:
- Every
specfield exceptapprovedandapprovedByis immutable. spec.approvedcan change only from unset orfalsetotrue, and oncetrueit andspec.approvedBynever change.- An approval is refused when the stored object’s
captf.io/plan-phaselabel isApplied,SupersededorFailed. spec.approvedBymust equal the username of the requester.- Only the manager’s ServiceAccount may change or remove the
captf.io/plan-phaselabel.
Lifecycle¶
- Create. When a change needs an approval, the manager runs a plan Job and creates one
TerraformPlanfor the result, with the plan’s counts and resources. A plan with no change creates no object. - Approve. Setting
spec.approvedmoves the plan toApproved. The target’s apply runs with--expect-plan=<spec.planHash>and applies only if it plans exactly those changes. A pool’sExportsChangeplan binds the approval hash inspec.inputsHashinstead (--allow-deletes-hash). - Finish. A successful apply makes the plan
Applied. An apply that found other changes makes itFailed; a failed step keeps itApproved. A newer plan, or a change that makes the plan moot, makes itSuperseded. - Delete. The plan is owned by its target and is garbage-collected with it.
- Move.
clusterctl movecarries the plan with its target through the owner reference, andcaptf.io/plan-phasekeeps its phase. Status is rebuilt on the target cluster.