0116. Patch upgrades can be applied automatically when a setting allows it

Status: Accepted · Date: 2026-11-04 · Design section: Upgrades · Touches: 0043, 0057, 0059, 0095, 0099, 0105, 0114, 0115

Decided: IchorInstallation.spec.autoPatch (and the chart's autoPatch, three controller flags) is a setting that is off by default; when it is on, a leader-only controller loop, once a minute, starts at most one upgrade TalosOperation per cluster for the newest patch of the running minor of Talos, then Cilium, then Kubernetes, taken only from the candidates the release check recorded in status.upgradeTargets and filtered by the compatibility table, only inside the configured daily UTC window, only while every approved machine is Ready and nothing else holds the cluster; the operation carries the annotation ichor.maesh.dev/auto-patch: "true" and started-by: ichor-auto-patch and runs through the normal executor; a failed or paused automatic operation halts the patcher (condition AutoPatchHalted, status.autoPatch.haltedOperation) until a person writes spec.autoPatch.acknowledgedHalt, or the operation completes or is deleted, or a later manual upgrade of that cluster completes; the patcher never retries. The controller itself never writes a TalosCluster spec: the version of a Talos or Kubernetes patch, and the operation, are written by a separate identity, the ServiceAccount ichor-auto-patch, which the controller impersonates (a namespaced Role that names that one ServiceAccount); the account holds only get and update on talosclusters and create on talosoperations, and the admission webhook refuses any write of it except a newer patch of the same minor in those two fields, and any spec write by the controller's own username. The audit log shows the writes as system:serviceaccount:<namespace>:ichor-auto-patch.

Context

The user decided on 2026-11-04 that patch upgrades may be applied automatically when a setting allows it. ADR 0114 made the controller list releases, ADR 0115 made each component upgrade on its own against one compatibility table and put the candidate lists in status.upgradeTargets (Talos, Kubernetes, and the Cilium patches whose images verified). Today a person starts each of those operations in the UI or CLI. The architecture says the controller never mutates spec and that the UI and CLI change the world by writing CRs. This ADR keeps the rule in as many places as it can and names the one place it cannot.

Decision

1. The setting

spec:
  autoPatch:
    enabled: true                         # unset falls back to --auto-patch (chart autoPatch.enabled), default false
    components: [talos, cilium, kubernetes]  # unset or empty means all three
    window: "02:00-05:00"                 # daily UTC window, may wrap midnight (22:00-02:00); unset means any time
    acknowledgedHalt: ""                  # see 5

All fields are optional and append-only. A set spec.autoPatch wins field by field over the flags (--auto-patch, --auto-patch-components, --auto-patch-window, from the chart's autoPatch.enabled, components, window); --settings-read-only ignores the whole spec group exactly as for the other settings (ADR 0105, 0114). status.effective gains autoPatch, autoPatchComponents and autoPatchWindow. Changing the first three needs update on ichorinstallations/settings, as the other settings do, and leaves the usual SettingsChanged Event; ichor settings set autoPatch.enabled=true autoPatch.components=talos,cilium autoPatch.window=02:00-05:00 and the Settings page (a Switch, one Checkbox per component, an Input for the window) write it through SettingsService. The webhook and the CRD both reject a malformed window or an unknown component.

2. What it may start

Only a patch: the same minor as what runs, newer, a final release. The candidate is the newest of release.TalosChoices, release.KubernetesChoices or release.CiliumChoices (the choices the UI offers, built from status.upgradeTargets and the table of ADR 0115) that has the running minor. So it is never a minor, never a version only --override would pass, never a version the release check did not list, and for Cilium only an image set whose signatures the check verified. Kubernetes needs the Cilium the cluster runs to be known (nothing is compatible with an unknown one), exactly as in the UI. The unmanaged CNI preset has no Cilium patch.

The order is Talos, Cilium, Kubernetes, one component per decision. Kubernetes comes last because its supported range is limited by the other two; a patch never changes a minor, so no component's patch makes another's candidate invalid. After one operation completes, the next minute's decision picks the next component.

3. When

Every guard is a field of the pure function autopatch.Decide, with a table test each:

  1. the setting is on, and not halted;
  2. no Ichor upgrade is in progress (status.installedVersion is spec.version);
  3. now (UTC) is inside the window;
  4. the release lists exist and are younger than 48 hours (the check runs every six hours, so older means it is failing or off);
  5. no TalosOperation of the cluster is unfinished (running, paused or pending; a node shutdown resting in ShutDown does not count), and the cluster lock (the Lease of ADR 0043) is not held;
  6. the cluster has approved machines and every one is Ready (a machine with a pending configuration change is not Ready, so a pending apply blocks it);
  7. no operation was started by the patcher on this cluster in the last two minutes (the guard for reads that have not caught up; the patcher reads without the cache as well).

4. How it runs: its own loop

controller.AutoPatcher is a leader-only Runnable that ticks every minute with jitter. It does not reuse the release check's loop: that one runs every six hours and the window is a time of day, so reusing it would miss most windows or force the check to run every minute. It does not hang off the TalosCluster reconcile either: that reconcile is per cluster and event-driven, while the halt state is global and the setting is read once. The release check stays the only thing that reaches a registry; the patcher only reads what it wrote. While the setting is off and nothing is halted the tick returns after reading the settings.

A start, for Talos or Kubernetes: the operation's creation is dry-run first, then the version is written into the cluster's spec as ichor-auto-patch (compare-and-set: only if the field still holds what was decided on, so a person's edit in between is not overwritten), then the operation is created, also as ichor-auto-patch. This is the order the UI uses because the executors read the target from the spec when a machine's turn comes. For Cilium the operation carries the target itself (spec.cni.chartVersion and imageVersion, as StartCniUpgrade does). The operation gets ichor.maesh.dev/from-version, ichor.maesh.dev/started-by: ichor-auto-patch and ichor.maesh.dev/auto-patch: "true"; the UI's operation page shows "Started by: Automatic patching". There is no spec field for the starter: the annotations are what the audit trail and the UI read, as for started-by today. Each start emits a Normal Event AutoPatchStarted on the TalosCluster and records status.autoPatch.lastOperation. If the version was written but the operation could not be created in three tries, the patcher halts and says so; it does not leave the half-start unseen.

5. Halt and resume

An automatic operation (by the annotation) in phase Paused, which is how a failed step and a person's pause both show (ADR 0095), halts the patcher: status.conditions[AutoPatchHalted]=True with the operation and its failure reason, status.autoPatch.haltedOperation: <namespace>/<name>, a Warning Event AutoPatchHalted, and an item in the Overview's attention list. While halted nothing starts on any cluster, and nothing is ever retried by the patcher: running a failed step again stays ichor operation resume (ADR 0095).

The halt ends when:

  • a person writes spec.autoPatch.acknowledgedHalt equal to haltedOperation (the Settings page's "Clear the halt" button, ichor settings set autoPatch.acknowledgeHalt=<namespace>/<name>). The controller never edits spec, so it only compares; the value is not removed, and a later halt names another operation, so an old acknowledgement does not clear it. This is allowed under --settings-read-only too (still needing update on ichorinstallations/settings): it clears a state Ichor wrote, it is not one of the deployment's settings;
  • the halting operation completed (a person resumed it) or no longer exists (deleted);
  • a later operation of the same cluster that the patcher did not start, of kind talos, kubernetes or cni, completed.

An acknowledged halt does not unblock a cluster: a paused operation still holds it (guard 5) until a person resumes or deletes it.

6. Who writes the version: a delegated identity, not the controller

The target of a Talos or Kubernetes upgrade is TalosCluster.spec.talosVersion / kubernetesVersion, which the executors (talos_upgrade.go, kubernetes_upgrade.go) read and the renderer (render.go) uses for every node's machine configuration. An operation that carried its own version would leave the spec behind the nodes, and the next reconcile would render the old version back. So the spec has to change, and the architecture says the controller never writes spec (principle 5). The rule is kept whole: the write is made by another identity acting for the user who turned the setting on.

  • ichor-auto-patch is a ServiceAccount in the controller's namespace with a ClusterRole ichor-auto-patch-role of three rules: get and update on talosclusters, create on talosoperations. Nothing else, and nobody else is bound to it. It writes the version and creates the operation, so both are attributed to it. Config (config/rbac/ichor-auto-patch/) and chart (templates/rbac/ichor-auto-patch.yaml) are identical and a chart test compares them. Its objects carry the installer's label like every other installed object, so the prune of ADR 0111 treats them as any other; the installer's bind/escalate lists name the ClusterRole and the Role.
  • The controller impersonates it. AutoPatcher.Actor is a client built with Impersonate: system:serviceaccount:<ns>:ichor-auto-patch. The controller's right is impersonate on serviceaccounts with resourceNames: [ichor-auto-patch] in a namespaced Role, so it can act as that one account in its own namespace and no other. Its own ClusterRole holds no update or patch on talosclusters (a chart test asserts it). Cilium takes the same path for uniformity, though its operation carries the target itself.
  • The webhook limits both. TalosClusterCustomValidator refuses any update of a TalosCluster spec by the controller's own username, and limits the ichor-auto-patch username to one change: spec.talosVersion or spec.kubernetesVersion to a newer patch of the same minor, with labels, annotations, finalizers and every other spec field unchanged. The compatibility webhook and both executors check the step again. A bug in the patcher therefore cannot edit a cluster's configuration.
  • A GitOps tool that owns the TalosCluster will see the version drift and revert it, so a deployment that keeps clusters in Git should leave auto-patch off (the default).

7. Not decided here

  • A separate maintenance window per cluster, or a per-cluster switch: the setting is installation-wide. A cluster wanting to opt out keeps the hold annotation on every machine (ADR 0043), which the executors honour.
  • Unsetting a field of spec.autoPatch from the UI or CLI (the same gap as the other settings, ADR 0105). An empty window written through the UI falls back to the chart's.
  • Auto-applying anything else: minors, Ichor itself, and Cilium chart upgrades stay manual.

Consequences

  • Off by default; nothing changes for an installation that does not turn it on. The CRD gains optional fields only (append-only), the chart a value group and three flags, the proto optional fields.
  • A new ServiceAccount, ClusterRole and binding ichor-auto-patch, and a namespaced Role that lets the controller impersonate that account only; the controller's own ClusterRole is unchanged (manager-role equals config/rbac/role.yaml as before). The installer's escalation lists name the new Role and ClusterRole.
  • The release check must be on for there to be anything to apply; with it off status.upgradeTargets is cleared and the patcher starts nothing.
  • ichor settings get and the Settings page show the effective switch, components and window, and the halted operation.
  • Tests: table tests of autopatch.Decide for every guard and of autopatch.Resume; an envtest of AutoPatcher for start (annotations, spec bump, no second start), the guards that need a cluster, halt on a paused operation, resume by acknowledgement and by a later manual operation; the webhook tests of the automatic patcher's limited write and of the controller's refused one, the chart test of the identity and of the settings gate (including read-only and the acknowledgement); the uiapi tests of the new fields; the UI's pure tests of the form.

Amendment 2026-11-04: Cilium has no automatic patch

ADR 0115, amendment 2026-11-04 (3), removed the Cilium patches that run on the embedded chart, so there is nothing for the patcher to apply for Cilium: autopatch.Candidate("cilium", ...) returns nothing and Cluster.CiliumChart is gone. cilium remains an accepted value of components (stored settings and the chart defaults keep working) and selects no operation. The patcher's order is Talos, then Kubernetes. A Cilium patch reaches a cluster with an Ichor release; the chart upgrade stays a manual operation.

Search the docs

Search pages and headings