Changelog
Changes to the module contract, newest first. Within v1alpha* there is no
compatibility guarantee (see README.md
“Versioning”); every change is still recorded here.
Unreleased
Added
- Machinepool role, including native autoscaling (additive
within v1alpha1;
machinepool.md,README.md“Roles and CAPI mapping”): the v1 controller now reconcilesTerraformMachinePool. New API kindsTerraformMachinePoolandTerraformMachinePoolTemplate, and new fieldTerraformMachinePoolSpec.membershipRefreshIntervalSeconds(default 60, 15–86400, rejected below 15 by the CRD schema, not the webhook). NewOutputsValidreasonInstancesTruncated, set whenstatus.instancesis capped at 1000 entries. Manager flag--terraformmachinepool-concurrency(default 10). The controller does not watch the bootstrap Secret (a rotated token reaches the pool at its next reconcile, at the latest one membership-refresh interval later) and does not support MachinePool Machines. - Machinepool autoscaling (additive within v1alpha1;
machinepool.md“Inputs”autoscaling/replicas, “Lifecycle” “Drift order” and “Write-back”): theautoscalinginput is parsed from the MachinePool’scluster-api-autoscaler-node-group-min-size/-max-sizeannotations; when enabled,replicasrendersstatus.replicas(falling back toMachinePool.spec.replicasbefore the first observation) clamped into[min,max], is excluded from the inputs hash, and the controller writes the raw observed value back toMachinePool.spec.replicasand claimscluster.x-k8s.io/replicas-managed-by: captf(never a foreign truthy value), removing its own claim when autoscaling is disabled again. New conditionAutoscalingActive(informational, pool-only, never inReady; reasonsReplicasManagedByModule,AutoscalingDisabled,AutoscalingAnnotationsInvalid). New eventReplicasWrittenBack. New RBAC:patchoncluster.x-k8s.iomachinepools(write-back needs it;get/list/watchalready covered reads). Drift for a pool already ranapply -refresh-onlythenplanlike every other kind; nothing changed there — it is the module’slifecycle { ignore_changes }on the desired-count attribute that keeps a cloud-side scale from reporting as drift, and that responsibility is now load-bearing rather than theoretical. Upgrade behavior: a MachinePool that already carries both autoscaler annotations before the upgrade starts autoscaled and re-applies once right after it — itsautoscalinginput flips from{enabled=false,min=0,max=0}to the parsed, enabled value, which is hashed, so the inputs hash changes and the pool re-applies. - Plan preview and approval (the module contract is unchanged; see
Plan Approval):
TerraformCluster and TerraformClusterTemplate gain
spec.applyPolicy(Automatic, the default applied at reconcile, orManual; mutable, not hashed). UnderManualevery apply but the first (no state yet), including a drift remediation and a retry, first runs a new operation,plan(added to theOperationenum): a Job that runsinit,validate,plan -outandshow -jsonunder the run lease only and applies nothing. Its plan (counts, up to 50"<address> (<action>)"entries, never values, and a plan hashp1:<sha256>of the sortedaddress|actionsof every change) goes to the newstatus.plan(inputsHash,job,planHash,add,change,destroy,resources,truncated,createdAt), and the apply waits until the newcaptf.io/approve-planannotation names that hash. The approved apply (runner flag--expect-plan) plans again and applies only an identical plan; otherwise it stops with the newRunErrorKindplan-changedand the new plan waits for its own approval. A plan without changes never waits. Approving a plan also approves its deletes; the destructive-plan annotation is not needed on top. The annotation is removed andstatus.plancleared once the approved apply succeeds. NewApplyJobSucceededUnknown reasonsPlanAwaitingApprovalandPlanChanged; eventsPlanReady,PlanApproved,PlanApplied,PlanChanged; metriccaptf_plan_approvals_total{kind,result}(approved,changed) andcaptf_jobs_totalresultplan_changed; Job annotationscaptf.io/approved-plan,captf.io/plan-changedandcaptf.io/plan-unreadable. - State backups and restore (the module contract is unchanged; see
Terraform State and
State Restore):
every new state serial the manager
reads is copied verbatim into
captf-state-backup-<suffix>-<serial>Secrets (plus-part-Nper chunk), owned by the Terraform* object and labeled forclusterctl move; the newest--state-backups(default 5, 0 disables) are kept. TerraformCluster and TerraformMachine gainstatus.stateBackups(up to 16{serial, takenAt, bytes}). Thecaptf.io/restore-state: "<serial>"annotation starts a new operation,restore(added to theOperationenum ofstatus.activeJobandstatus.lastRun): a Job that runsinit,state push -forceof the backup andstate list, under the run lease (and the cluster write lease for a TerraformCluster). It precedes apply, drift and refresh but not deletion; the annotation is removed on success, and a failure is not retried for the same serial. New conditionRestoreJobSucceeded(never inReady) with reasonsStateRestored,RestoreFailed,RestoreBackupNotFoundand the three lease waits; eventsStateBackedUp,StateRestored,StateRestoreFailed; metricscaptf_state_backups_total{kind,result}andcaptf_state_restores_total{kind,result}; runner stepsstate-pushandstate-list. A TerraformCluster’s restore takes part in the cluster operation gate like its apply. - User variables (see
common.md“User variables” and Module Variables): TerraformCluster and TerraformMachine (and both templates) gainspec.variables, an inline JSON object, andspec.variablesFrom, up to 16 ConfigMaps or Secrets labeledcaptf.io/variables=true, eachoptionaland read informatString(default) orJSON. Sources merge in list order, a later one winning; inline variables win over all. Each variable becomes a named argument ofmodule "role", declared in the generated root without a type (sensitive = truewhen its value came from a Secret) and valued interraform.tfvars.json; a name the module does not declare fails the apply. Reserved:captf_names, the role’s contract inputs and the module meta-arguments. The inputs hash covers the variables only when an object sets some, so existing objects keep theirh2hash and do not re-apply. On a TerraformCluster a changed variable or source re-applies; on a TerraformMachine both fields are immutable and read only until the machine is provisioned. NewDependenciesReadyFalse reasonsVariablesSourceNotFoundandVariablesInvalid. The manager’s ClusterRole gainsget,listandwatchonconfigmaps, and it runs a second, label-scoped cache (captf.io/variables=true, data stripped) for the source watches. - Run leases (the module contract is unchanged; see
The Reconcile Lifecycle): the manager
holds a
coordination.k8s.io/v1Leasecaptf-run-<suffix>per object before it creates a Job, so a stale Job cache, a leader-election handover or two manager instances with overlapping--watch-filtervalues cannot run two Jobs of one object. With the new--cluster-operation-gate(default true) a TerraformCluster’s apply or destroy and its machines’ applies and destroys never run at once (cluster write Leasecaptf-cluster-<hash>); refresh and drift are not gated. NewApplyJobSucceededUnknown reasonsWaitingForRunLease,WaitingForClusterOperationandWaitingForMachineOperations(DriftJobSucceededgainsWaitingForRunLease), the Normal events of the same names once per wait, andcaptf_lease_waits_total{kind,reason}. The manager’s ClusterRole gainscreateandupdateonleases. - Events for every stage (the module contract is unchanged; see
Observability “Events”): the
runner now reports its progress as
events.k8s.io/v1Events on the object that owns the Job (RunStarted,StepStarted,StepSucceeded,StepFailed,PlanSummary,ResourcesChanged,RunFinished; reporting controllercaptf.io/runner), best effort and never failing the run. Job pods get two new runner flags,--event-objectand--job-name; the manager’s new--runner-events(default true) turns them off. Thecaptf-runnerClusterRole gainsevents.k8s.ioeventscreate. The manager emits new reasons once per transition:JobSucceeded,JobInterrupted,JobDeadlineExceeded,StuckJobDeleted,DestructivePlanApprovalConsumed,DeletionStarted,FinalizerRemoved,Paused,Resumed,ProviderIDSet,ControlPlaneEndpointSet,FailureDomainsChanged,InputsChanged,StateAdopted,StateLost,StateLocked(both formerlyStateUnreadable),DriftResolved,DriftRemediationStarted,InstanceHealthy,InstanceUnhealthy,MirrorCreated,MirrorRemoved,IdentitySecretFound,IdentitySecretNotFound,CapacityResolved, andConditionChangedfor any other owned condition’s status or reason change.JobFailednow also covers failed drift and refresh Jobs;JobCreatednames the attempt, the image and why the Job started. - Destructive-plan guard: a TerraformCluster
apply, including a drift remediation, runs
plan -out,show -jsonand an apply of the saved plan, and stops before a plan whose actions includedelete(a removal or a replacement) unless thecaptf.io/approve-destructive-planannotation names the inputs hash it renders. Newstatus.lastRun.error.kind: blocked, reasonApplyJobSucceeded=False/DestructivePlanBlocked, eventDestructivePlanBlockedandcaptf_jobs_total{result="blocked"}. The image’s runtime must supportplan -out=<file>andshow -json <planfile>(see Image Contract). Machine applies are unchanged. - Controller remediation additions (the module contract is unchanged):
- New
remediation.healthCheckIntervalSeconds(60–86400, default 300): withannotateMachine, a provisioned machine is refreshed at that interval to sample health, independent of drift. A health sample is one completed refresh or drift Job, not a new state serial. - New condition reasons:
StateReadable=False/StateLost(a provisioned object’s state is gone or lost its inputs hash; no Job runs),StateReadable=False/StateLocked(the state lock is held by something other than the object’s runner) andApplyJobSucceeded=False/InputsTooLarge.DriftDetectedstarts asUnknown/DriftNotChecked. captf_job_attemptsrecords the retry number of a successful Job (failures of that op since its last success, plus one);status.activeJob.attemptis the Job’s sequence number.
- New
tfcapi-lint’sinput/extraerror is replaced by theinput/user-variable-defaultwarning (a non-contract variable without a default is legitimate when every object is meant to set it throughspec.variables/spec.variablesFrom).
Changed
- The contract pages (
README.md,common.md,cluster.md,machine.md,machinepool.mdand this changelog) were reorganized for readability: long paragraphs were split into sections, table cells were shortened with their detail moved into a section below each table, and source citations became links. The contract itself did not change: every MUST/SHOULD/MAY requirement, every schema and every skeleton is unchanged. - Fewer Jobs per bring-up and in steady state (the module contract is
unchanged; see Machine Remediation,
Plan Approval and
The Reconcile Lifecycle): a
TerraformMachine no
longer refreshes after a successful apply whose own outputs are valid
with a
health.stateother thanpendingorunknown; that reading is the post-apply health sample (status.lastRefreshis the apply’s finish, and it counts once towardstatus.unhealthySamples). While health ispendingthe refresh backs off 30s, 1m, 2m, 4m, then every 5m (plus the UID jitter) instead of a fixed 30s; TerraformCluster and TerraformMachine gainstatus.pendingRefreshes, the consecutive pending samples since the last other reading or apply (status only; it restarts afterclusterctl move). A guarded or approved (--expect-plan) apply whose plan has no changes skips the apply step and succeeds right after the plan, with zero changes and noResourcesChangedevent, so its leases go as soon as it is bookkept. The example bring-up (one cluster, six machines, one no-change re-apply) drops from 14 Jobs to 8. - Controller remediation behavior (the module contract is unchanged):
- The
cluster.x-k8s.io/remediate-machineannotation CAPTF set (markedcaptf.io/remediation-requested) is removed once the instance reads Healthy and the Machine is not being deleted. - The first drift check runs one interval after the last successful apply; drift and health deadlines get a per-object jitter of up to 10%.
- A failed apply is retried even when the inputs equal the state’s hash. An image change clears the pinned digest in the durable inputs Secret.
- The
TerraformMachine.spec.driftandspec.defaults.driftare aMachineDriftPolicywithintervalSecondsonly: machine drift is always reported. The cluster’sspec.drift.actiondefaults toReport, notRemediate.- Machine jobs and drift policies are merged field by field over the
cluster’s
spec.defaults(env by name,imagePullSecretsas a union), instead of replacing them as a whole.spec.defaultsno longer applies to the TerraformCluster itself. TerraformMachine.spec.jobs,driftandremediationare mutable;sourceandidentityRefstay immutable.jobs.lockTimeoutSecondsis optional as a pointer;jobs.activeDeadlineSeconds(at most 86400) andremediation.unhealthyThresholddefault when unset (0), not through a pointer. A policy that sets bothlockTimeoutSecondsandactiveDeadlineSecondsmust keep the lock timeout below the deadline.jobs.securityContextmay not setprivileged,allowPrivilegeEscalationorcapabilities.add.TerraformCluster.spec.identityRefis required; machines fall back tospec.defaults.identityRef, then tospec.identityRef.spec.controlPlaneEndpointis immutable once it has a host.TerraformClusterIdentity.spec.allowedNamespaces: {}is rejected; writeselector: {}to allow every namespace. The identity has a status (conditions,namespaces), filled by the manager.status.lastRun.error.tailis nowsummary(at most 512 bytes): the runner’s summary of the failure, not raw stderr.captf_cluster_outputsis not passed to the cluster role. Previously the text said it wasnull. The generated cluster root declares no such variable, and its tfvars carry no key. A cluster module may leave it undeclared or declare it withdefault = null, as the skeletons do. A declaration without a default failsvalidate. Updatedcommon.md,cluster.md, and thecluster-inputs.jsondescription; the schema still acceptsnull.- Consequence of the inputs hash: the hash accepts only integer numbers. The
cluster role’s
exportsvalue becomes every machine’scaptf_cluster_outputsinput, which is hashed. So a fractional or exponent number anywhere inexports(for example{"ratio": 0.5}) makes those machines’ inputs unhashable, and they cannot apply. Module authors should export numbers as integers or strings. The controller reports it asOutputsValid=False/OutputsInvalidonexports. Notfcapi-lintcheck catches this today. The alternative, a canonical float encoding in the hash, was not chosen, because the design calls for integers only and a float that does not round-trip exactly would change the hash silently. - API field names: the drift and job durations are integer seconds,
following the Cluster API v1beta2 convention and the kube-api-linter
nodurationsrule.spec.drift.intervalis nowspec.drift.intervalSeconds,spec.drift.refreshIntervalisspec.drift.refreshIntervalSeconds, andjobs.lockTimeoutisjobs.lockTimeoutSeconds. Defaults are unchanged (1800, pool-only 60 with a minimum of 15, and 300 seconds). References in these documents were renamed.
Removed
jobs.backoffLimit(Jobs always getbackoffLimit: 0) andjobs.ttlSecondsAfterFinished(Jobs never get a TTL): the controller owns retries, and backoff, digest pinning and conditions are derived from the Jobs it retains.spec.drift.refreshIntervalSeconds(it did nothing),spec.source.imagePullSecrets(usejobs.imagePullSecrets, which covers the source and runner images) andspec.source.command(the image contract fixes/captf/runtime; the image’s/captf/runtimeis now the only executable the runner starts). The durable inputs Secret no longer carriescaptf.io/command, and the inputs hash no longer covers a command: the scheme is nowh2, so every provisioned mutable object re-applies once after the upgrade.- The five no-op mutating webhooks; only validating webhooks remain.
Unused condition reasons
RBACPending,StateReadPendingandCapacityResolving.
Fixed
machinepool.md’s “Minimal skeleton” gavefailure_domainsandcluster_failure_domainsadefault = []; both are always set (non-null) by the controller, sotfcapi-lint’sinput/defaultcheck warned on either one, and--strictfailed. The defaults are removed; the inputs are documented as always set (may be[]), matchingmodules/noop/machinepool. Every role’s “Minimal skeleton” (cluster.md,machine.md,machinepool.md) now also referencescaptf_tags(an emptyterraform_datastub), since the skeletons are now actually run throughtfcapi-lint module --role <role> --strict, which otherwise warnsinput/tags-unusedon all three.cluster.md’s “Minimal skeleton” typedcluster_network’s attributes withoptional(...), while the Inputs table types them as required and the controller always renders all four (an unset CIDR list as[], an unset scalar asnull, never omitted). Both forms passtfcapi-lint’sinput/typecheck, but the skeleton now matches the table.- The
output/provider-id-list-shapecheck registers at botherror(the output is missing or not a list) andwarning(the expression is not sorted and deduplicated);reference/tfcapi-lint-cli.mdlisted it as two separate rows with the same description and no way to tell them apart. It is now one row whose Severity cell names both. machinepool.mdsaidprovider_id_list’s order was irrelevant (true for the controller’s own write, which sorts and de-duplicates) without mentioning thattfcapi-lintseparately checks the output’s own expression forsort()/distinct(), and the skeleton’s comment pointed atmodules/noop/machinepool/outputs.tfinstead of explaining why. Both are now explained inline, with the pattern shown directly.machinepool.md’snode_labelssaid only that the module “MUST render these into kubelet registration … through the bootstrap/agent config it controls,” without saying how, whilebootstrap_datais opaque and may be gzipped or Ignition. The realistic options (amultipart/mixedcloud-config extra part, with a worked example; Ignition and gzipped payloads needing format-aware handling; or a module declaring only the formats it supports) are now spelled out. No new MUST or SHOULD is added.runtime-environment.mddid not say that a provider’s own debug logging (TF_LOG) cannot be turned on for a Job: it is dropped like every otherTF_*name, andspec.jobs.envrejects it outright. The page now says so and points at running the pinned image locally against copies of the rendered inputs (the stuck-destroy runbook’s recipe) withTF_LOGset, as the supported way to get provider debug output.runtime-environment.mddid not say that a network provider mirror or a private registry cannot be configured at run time:TF_CLI_CONFIG_FILEis runner-owned and every otherTF_*name is dropped. The page now states this and points at a filesystem mirror baked into the image (/captf/providers) as the supported path.machinepool.mdandcommon.mddescribedhealth,instancesand their controller-side effects individually, without saying how a pool module should combine mixedinstances[*].statevalues into one grouphealth— the controller does not aggregate them itself.machinepool.md“Per-instance state” now states what the controller does (nothing: both outputs are copied independently) and what a module SHOULD do to derivehealthfrominstances(a new SHOULD, not a MUST).image-contract.mdrecommended theorg.opencontainers.image.*labels but the reference Containerfiles (docs/book/src/module-author/examples/Containerfile.terraformand.opentofu) never set them. Both now takeIMAGE_SOURCE,IMAGE_REVISIONandIMAGE_VERSIONbuild args (empty by default) and set the three labels from them.
v1alpha1 (provisional freeze)
Frozen for implementation: Go types and the controller are built against this version. It may still change until the first real module has provisioned a cluster (the contract is frozen for good only once one real cloud module has created a cluster); every such change is recorded here.
Scope
README.md,common.md,cluster.md,machine.md,machinepool.mdandschemas/in this directory.- Image Contract and the reference
Containerfiles (
docs/book/src/module-author/examples/,modules/noop/). - The control-plane guides in Control-Plane Integration.
Changed at the freeze
- Provider mirror wildcard. The runner writes
include = ["*/*/*"]andexclude = ["*/*/*"], not*/*. Verified with the pinned runtimes Terraform v1.16.4 and OpenTofu v1.12.6, and separately with OpenTofu v1.11.5:*/*matches providers on the default registry host only, so a provider from any other host fell through todirect;*/*/*matches every host. - Remediation wording (
machine.md“Health → remediation”). Remediation happens for Machines whose owner acts onMachineOwnerRemediated: a MachineSet or a control-plane provider that implements remediation (KubeadmControlPlane, RKE2ControlPlane), not only a MachineSet or KubeadmControlPlane.
Added at the freeze
- Reserved paths.
/captf/bin(the injected runner) and/captf/config(the per-run Secret) are mount points; an image MUST NOT ship anything there. Added to the image contract’s “Fixed paths” table and thetfcapi-lint imagechecklist. - Machine-checkable JSON Schemas (draft 2020-12) in
schemas/:definitions.json,cluster-inputs.json,cluster-outputs.json,machine-inputs.jsonandmachine-outputs.json, with a valid and an invalid example for each. The schemas change nothing in the contract; they encode it.- Inputs are closed at every object level (module inputs are exactly the contract inputs); outputs are open (extra, non-contract outputs are ignored).
- Outputs are validated after the controller’s normalization:
""for an ID such asprovider_idbecomesnullfirst, so the schema requires 1–512 characters for a non-nullprovider_id. - Limits come from the CAPI v1.14.2 API markers, including three the role
documents do not spell out:
service_domain1–253 characters, at most 100 CIDR blocks of 1–43 characters each,api_server_port1–65535. - Not expressible in the schemas and enforced by the controller: unique
failure-domain names, the canonical address order, and a machine’s
failure_domainoutput equal to its input when one was requested.
- The image contract’s reference Containerfiles create
/captf/providersbefore runningproviders mirror. Neitherterraform providers mirror(v1.16.4, in the reference build) nortofu providers mirror(checked with OpenTofu v1.11.5 on the build host) creates the target directory when the module requires no providers, so the previous recipe failed withlstat /captf/providers: no such file or directoryfor a provider-less module (found building the reference images).
Decisions
- Operator decisions: native pool autoscaling, runner Secret access accepted and documented (see Security Model), static credential Secrets only in v1.
- Every candidate CAPI mapping considered was decided; the adopted ones
are in the role documents (e.g. address order,
interruptible, clusterkubernetes_version,control_plane_initialized, endpoint provenance, emptydataSecretName,captf.io/template). - The role documents’ claims were checked against CAPI source and
resolved. Resolutions for the items raised in the RKE2ControlPlane
guide:
- binary bootstrap data: resolved by always-base64
bootstrap_data; - providerID under RKE2: no contract change; the machine module makes
the kubelet
provider-idmatch (CCM or kubelet extra args), as the guide and checklist say; - a second listener on 9345: a cluster-module convention, not a contract field, since CAPI has nowhere to put it;
- the
InternalIPguarantee: no contract change; notfcapi-lintcheck exists for it ininternal/lint; - the version string: stays verbatim; modules strip
+rke2rN(asmachine.mdsays), because normalizing would change the inputs hash; - remediation wording: fixed above;
- CAPRKE2 on CAPI v1.14: needs a compatibility smoke test against CAPI v1.14.2 before RKE2ControlPlane is relied on in production;
- a nondeterministic join target: upstream behavior, documented in the guide.
- binary bootstrap data: resolved by always-base64
- The contract is authoritative on
captf_contract: every role receives it (common.md), and the renderer renders it for every role. - Proposals considered and not adopted for
common.md,cluster.md,machine.mdandmachinepool.md(extra tags,captf_identity,health.observed_at, aresourcesinventory output, clustertags/dns_zone, machineimage/instance_type/disk/ssh_authorized_keys/machine_uid,failure_domain_attributes,bootstrap_data_hash,instance_id, poolrolling_updateandready_replicas): none loosens the closed input schemas (schemas/) or is needed by a shipped module; several are fixed in the module instead because the CRDs have no free-form per-object variables outsidespec.variables/spec.variablesFrom.