# 0026. `TalosOperation`: the backbone every upgrade kind shares, with none of their execution

Status: Accepted · Date: 2026-10-25 · Design section: Upgrades

Decided: `TalosOperation`: the backbone every upgrade kind shares (lock, status-held state machine, pause-on-failure, paused, maxUnavailable, hold annotation, ordering), with none of their execution

## Context

`docs/design/architecture.md`'s "Upgrades" section opens with what all three
upgrade kinds (Talos, Kubernetes, Ichor) share:

> All three upgrade kinds share the lock, the status-held state machine,
> pause-on-failure (no automatic cluster-wide rollback), `paused`,
> `maxUnavailable` for workers, a per-machine hold annotation, label-based
> ordering, and a progress view.

This unit builds exactly that list, scaffolded with `kubebuilder create api
--group ichor --version v1alpha1 --kind TalosOperation --resource
--controller`, and nothing else: no Talos Upgrade API call, no Kubernetes
upgrade execution, no Ichor self-upgrade, no UI, no `ichor` CLI command.
Each of those is a separate, later unit.

## Decision: an injected `AdvanceMachineFunc`, not a per-kind branch

`spec.kind` (`talos`/`kubernetes`/`ichor`) is stored and reported, never
branched on. `TalosOperationReconciler.Advance` is a single function field
-- `func(ctx, cluster, machine, op) (done bool, err error)` -- that performs
one disruptive step for one machine and reports whether that step finished.
`nil` (the default, until a later unit wires a real implementation into
`cmd/main.go`) means "pause with `NoExecutor`," not a panic.

Declared in `internal/controller`, the consumer, not in `pkg/upgrade`: ADR
0022 left exactly this kind of interface undeclared on purpose, because its
consumer didn't exist yet ("Go interfaces belong to their consumer, declared
where they're consumed"). This reconciler is that consumer. Each upgrade
kind's own implementation -- calling Talos's Upgrade API, driving a
Kubernetes upgrade, launching Ichor's installer Job -- is later, separate
work that plugs into this one seam; the state machine itself never needs to
know which kind it is running.

## Decision: the lock, reused exactly as built, never called directly

`TalosOperationReconciler.acquireDisruptiveLock` is a second function with
that exact name (`internal/controller/talosoperation_controller.go`),
alongside `TalosMachineReconciler`'s own
(`internal/controller/removal.go`). `TestAcquireDisruptiveLockIsTheOnlyAcquireCallSite`
matches on the enclosing function's *name*, not its receiver type, so a
second, independently-named-the-same function satisfies the guard exactly
the way the first one does -- no change to that test was needed.

It refuses the same way `refuseReboot`'s call does: recompute the live,
approved control-plane count (now via
`deleteguard.CountApprovedControlPlanes`, read directly rather than through
the manager's cache -- see the companion fix landed just before this unit,
ADR 0010's appendix), and refuse with `*transitioningError` if it is even,
before ever calling `Acquire`. Unlike `reconcileRemoval`, `TalosOperation`
has no `completesTransition` exception: there is no scenario where an
upgrade operation is itself the step that resolves a control-plane
replacement, so this call always passes `false` (implemented by simply never
exposing the parameter).

`pkg/clusterlock.Lock.Renew` -- already built, documented, but with no
caller until removal.go's `renewWhile` gave it one -- gets a second: once
per reconcile batch, after selecting which machine(s) to advance and before
calling `Advance` on any of them, comfortably inside `DefaultDuration`'s five
minutes for the handful of calls one reconcile pass makes.

## Decision: status holds the whole state machine

`TalosOperationStatus` carries `phase` (`Pending`/`InProgress`/`Paused`/
`Completed`) and `machines` (`[]{name, phase: InProgress|Completed}`) --
nothing is kept in the reconciler's memory. A name's *absence* from
`machines` means "never selected" (held, not yet its turn, or the operation
finished before reaching it), not a third phase value forcing every reader
to special-case it.

This is what makes resumption automatic rather than a feature to build:
`Reconcile` always starts by reading `op.status` fresh and recomputing which
machines are still pending from it, so a killed and restarted controller
(or a second, independently constructed reconciler, which is what the test
for this uses to simulate one) picks up exactly where the persisted status
says the operation was, with no separate recovery path -- the same shape
`removal.go`'s own resumability section already documents for
`clusterlock.Lock.Acquire`'s self-resumption.

## Decision: pause-on-failure stops advancing, never undoes

A machine's `Advance` call returning an error sets `phase: Paused` and
`failureReason`, and returns. It does not touch any other machine's status
-- there is no code path that could revert an already-`Completed` entry,
because nothing ever removes an entry from `machines` or demotes its phase.
Once `phase == Paused` *and* `failureReason != ""` (distinguishing a failure
from `spec.paused`), `Reconcile`'s very next check short-circuits before
calling `Advance` again on anything: a paused-by-failure operation never
retries automatically. The only way out, in this unit's scope, is whatever
the operator does next (fix the underlying cause and -- once a later unit
adds the mechanism -- clear the failure); nothing here guesses at a retry
policy nobody asked for.

`spec.paused` is the separate, operator-driven halt: it sets `phase: Paused`
with a different reason (`OperatorPaused`) and, critically, *still holds the
cluster lock* -- the operation is not finished, only halted, so releasing
the lock would let an unrelated disruptive operation start on a cluster
that is mid-upgrade in every sense except "currently calling `Advance`."

## Decision: control planes first and alone, workers within `maxUnavailable`

Every reconcile computes `pendingMachines` for control planes first; as
long as any control plane has not reached `Completed`, exactly one of them
(not more) is the whole batch for that reconcile, and workers are never
touched. Only once every control plane shows `Completed` do workers become
eligible, up to `spec.maxUnavailable` (default 1) per reconcile. This is a
single `if/else` on "any control plane pending", not a counter or a phase
of its own -- the state already recorded per-machine is sufficient to derive
it fresh every time, consistent with principle 1.

## Decision: hold and ordering are machine-level, not operation-level

`ichorv1alpha1.TalosMachineUpgradeHoldAnnotation`
(`ichor.maesh.dev/upgrade-hold`) and
`ichorv1alpha1.TalosMachineUpgradeOrderLabel`
(`ichor.maesh.dev/upgrade-order`) are new constants in `api/v1alpha1`,
read off `TalosMachine` objects, not `TalosOperation` fields -- an operator
marks the machine, once, and every `TalosOperation` that ever touches it
honors the mark. A held machine is filtered out before it is ever a
candidate at all: it never appears in `status.machines`, and does not block
an operation from reaching `Completed` (completion only waits on machines
that were actually candidates). The order label sorts within a tier
(`sortByUpgradeOrder`, `slices.SortFunc`) by string value, a missing label
sorting last, with the object's own name as the final, deterministic
tiebreak -- which matters for tests more than production, since
`GenerateName`-assigned object names are not something a test should ever
rely on for ordering (a real bug this unit's own tests hit once, fixed by
setting the label explicitly rather than depending on name order).

## Decision: no Conditions populated, on purpose, for now

`TalosOperationStatus.Conditions` exists (the same `[]metav1.Condition`
shape every other reconciled CRD here carries) but nothing in this unit
ever calls `apimeta.SetStatusCondition` on it. `phase`, `machines` and
`failureReason` already say everything this unit needs to say, and nothing
in the acceptance criteria asks for a condition-shaped signal on top of
them. The field stays in the schema (removing it would be a disruptive
schema change for whichever later unit decides it wants one) but is
inert until something actually needs it -- deletion-by-default, not
speculative API surface.

## Decision: not tracked in Git, and why that is this type's own doc comment, not a mechanism

`docs/design/architecture.md`'s "GitOps and Argo CD" section lists
`TalosOperation`s under "What doesn't [go in Git]" alongside Secrets: it is
a record of an imperative action in progress, not a declarative desired
state. `TalosOperation`'s own Go doc comment says so explicitly and points
at `ichor gitops export` as the thing responsible for excluding it --
that command, and its own exclusion logic, belong to a different agent's
work entirely. This type cannot enforce its own absence from a Git
repository; the most it can do is tell the next reader why, which is what
the doc comment does.

## Consequences

- `pkg/deleteguard.CountApprovedControlPlanes` (landed just before this
  unit, fixing a cached-read bug -- see ADR 0010's appendix) is reused
  directly by `TalosOperationReconciler.acquireDisruptiveLock`, via a new
  `APIReader client.Reader` field wired from `mgr.GetAPIReader()` in
  `cmd/main.go` -- the same pattern `TalosMachineReconciler` and the
  `TalosMachine` webhook now both use, so this new reconciler never
  reintroduces the trap that fix closed.
- `TalosOperationReconciler` is registered as its own controller in
  `cmd/main.go`, not folded into `TalosClusterReconciler` or
  `TalosMachineReconciler`, for the same work-queue-isolation reason ADR
  0024 gives for `CNIHandoverReconciler`.
- Not built here, and explicitly out of scope: any call to Talos's Upgrade
  API, Kubernetes upgrade execution, Ichor's self-upgrade, the UI's progress
  view, and the `ichor` CLI's own upgrade commands. `AdvanceMachineFunc`'s
  only implementation in this unit is test fakes.

- `IchorInstallation` and the `kind: ichor` executor landed in ADR 0059; the installer Job is a separate process, so the backbone is unchanged (the executor is idempotent per operation).

- The entry points landed in ADR 0068: `OperationService`, `ichor upgrade|cni|cluster shutdown|operation`, and the operations screens.

- Every pause now says why: `setPhase` writes a `Paused` condition (reason and message) for a transitioning cluster, a held lock, `spec.paused` and a failed step; `failureReason` stays reserved for failed steps because the controller, the CLI and the UI read it as "hold, never retry".

## Amendment (2026-11-04): each operation takes the cluster lock under its own identity

**What was wrong.** `OperationService` refuses a second operation while one is unfinished (ADR 0068, Decision 2), but the controller did not keep two operations apart. Every `TalosOperation` took the cluster lease as `ichor-controller`, and `clusterlock.Acquire` lets the same identity back in, so two operations created some other way (`kubectl`, GitOps, a script) ran their disruptive steps at the same time, and an operation also ran alongside a machine removal or join, which share that identity. The envtest `two TalosOperations on one cluster` showed it: the second operation called `Advance` while the first was mid-reboot.

**Decision.**

- An operation acquires, renews and releases the lock as `ichor-controller/op/<namespace>/<name>` (`operationLockIdentity`). The name, not the UID, is used because an etcd restore rolls the API back and the restored operation has to find its own lease (ADR 0089). The same operation re-enters its own lock across reconciles and controller restarts; any other operation waits.
- The one that waits pauses with `ClusterLockHeld` and the message `the cluster lock is held by TalosOperation "<namespace>/<name>"; this operation starts when it finishes`, requeued every 30 seconds. A lock held by anything else keeps the old wording (`held by "<identity>"`), which is also what a machine removal or join sees when an operation holds the lock: they defer with `ClusterLockHeld` as before.
- A holder that will never renew is not waited for. If the holder is an operation that no longer exists, is `Completed`, or is `Paused` with a `failureReason` (only an explicit retry moves it, and the retry takes the lock again), `acquireDisruptiveLock` releases that lease and takes it. Before this, a failed or deleted operation's lease lingered up to five minutes and the next operation had to wait it out; with per-operation identities that wait would have appeared in the "delete it, start again" flow. An operator pause (`spec.paused`) is not stopped in this sense and keeps the lock, as above.
- A lease left by the old build under `ichor-controller` is indistinguishable from a machine removal or join, which still use that identity, so it is treated as held by someone else: operations wait. Nothing renews it once the old controller is gone, so it ages out within `clusterlock.DefaultDuration` (five minutes). That is the whole upgrade story: an Ichor self-upgrade that replaces the controller pod mid-operation resumes after at most five minutes. No migration code.
- Not changed: the machine removal and join paths (one shared identity among machines of a cluster), and the CNI Helm Job (`ichor-controller/cni-helm`, already separate).

**The webhook does not reject a second operation.** The service refuses early so a person sees why before the spec is written; the controller serializes. A webhook refusal would break GitOps, where a repository can hold several operations that are meant to run one after another. The cost is a `Paused` operation with a visible reason instead of an error at creation.

**Follow-up (2026-11-04): a control-plane join and a machine removal take the lock under the machine's own identity.** The amendment above left the machine paths on the shared `ichor-controller`, so two control-plane joins, two removals, or a join and a removal on one cluster could hold the lock together (`clusterlock.Acquire` lets the same identity back in), and the later one also released the earlier one's lease when it finished. Etcd membership changes must run one at a time: a join adds a member, a removal removes one. The envtest `two control-plane TalosMachines of one cluster at a step that takes the cluster lock` showed all four pairings getting through.

- A machine acquires, renews and releases the lock as `ichor-controller/machine/<namespace>/<name>` (`machineLockIdentity`, from the name and not the UID, for the reason given above). The same machine re-enters its own lock across reconciles and restarts; any other machine or operation waits. The wait is `ClusterLockHeld` on the Ready condition (join) or the Removal condition (removal), worded `the cluster lock is held by TalosMachine "<namespace>/<name>"; control plane join deferred` (or `removal deferred`). A holder that is an operation reads `TalosOperation "<namespace>/<name>"`, and an operation that waits for a machine reads `... held by TalosMachine "<namespace>/<name>"; this operation starts when it finishes`. Any other holder, such as `ichor-controller/cni-helm`, keeps clusterlock's wording.
- A holder that will never renew is not waited for, whoever waits (`lockHolderStopped`, one function for operations and machines). A machine holder is stopped when the machine no longer exists, or when it is not being deleted and is done with its locked step. For a join that step is handing the configuration to the node in maintenance mode, which happens inside one reconcile and ends with `status.configStoredAt`, so a machine with `configStoredAt` set, or Ready, holds nothing. For a removal the step is the removal itself, which runs until the finalizer goes, so a machine with a deletion timestamp is not stopped; once the finalizer is gone it no longer exists and its lease is. There is no failed state to add for machines: a refused or failed join or removal releases the lock before it returns (the deferred `Release`), and the next reconcile takes it again. The lease is only ever left behind by a controller that died mid-step, which these rules turn from a five-minute wait into none when the machine is gone or finished.
- Upgrade: a lease the previous build left under `ichor-controller` is held by nobody this build can name, so machines wait for it as operations do, and it ages out within `clusterlock.DefaultDuration` (five minutes) because nothing renews it. No migration code.
- Worker joins take no lock and still do not: a worker is not an etcd member, and only a control-plane join reaches `acquireDisruptiveLock` (covered by `joins a worker while the cluster lock is held`). The removal of a worker that was reset (ADR 0085) still takes it, as before, to serialize its drain with everything else.
- The Cilium Helm Job (`ichor-controller/cni-helm`, ADR 0113) is unaffected: it was already a separate identity. A control-plane join still waits while it holds the lock, and the Job still waits for a machine or operation that holds it.
- Not changed, and worth knowing: the join holds the lock only while the configuration is handed over, not while the node installs, reboots and becomes an etcd member, and nothing in the lock keeps a second join or a removal from starting after the first reconcile ends. What the lock now guarantees is that two steps never overlap; the control-plane count (the even-count refusal and the approval cap) is what paces the membership changes across time.
