# 0059. IchorInstallation holds the target version; a TalosOperation of kind ichor runs an installer Job that carries its own manifests, snapshots, and rolls back

Status: Accepted; superseded in part by ADR 0090 (no air-gap requirement) and in part by ADR 0101 (decision 5 and the external mode) · Date: 2026-10-26 · Design section: Upgrades (Ichor), API
Decided: Add the cluster-scoped singleton `IchorInstallation` (managementMode, target version, installer repository; installed version reported by the controller), execute `kind: ichor` by creating a one-shot installer Job that runs the target version's own image and embedded manifest (snapshot in ConfigMaps, additive-only CRD check, CRDs then controller then UI with post-checks, rollback from the snapshot), refuse CLI writes more than one minor from the server, and never launch the Job in `external` mode

## Context

The design (Upgrades, Ichor) says: in `ichor` mode the controller only launches the installer Job for the
target version and never upgrades itself; the installer snapshots the current manifests, updates CRDs
(additive, never downgraded), the controller, then the UI, runs post-checks, and rolls back from the snapshot on
failure; in `external` mode it is a PR; the CLI refuses writes more than one minor from the server; the UI
backend sends its version in a header. ADR 0004 deferred `IchorInstallation` to this phase. ADR 0026 left
`kind: ichor` without an executor, ADR 0019 made `ichor init` apply a manifest embedded in the binary, and ADR
0056 wired PR mode into the UI backend.

## Decisions

**1. Shape, and who owns what.** `IchorInstallation` (cluster-scoped, must be named `ichor`, enforced by CEL):
`spec.managementMode` (`ichor` default, `external`), `spec.version` (semver with a leading `v`, required),
`spec.installerRepository` (default `ghcr.io/maeshinshin/ichor-installer`; point it at a mirror if needed).
`status.installedVersion` is written by the controller from its own build version (`internal/version`, linked
with `-ldflags`) and nothing else writes it; conditions `UpToDate` and `ExternalSuggested` (the latter when
the mode is `ichor` but `pkg/gitopstracking.IsTracked` finds the controller's own Deployment tracked).
The `TalosOperation` of kind `ichor` carries **no version**: the target is `IchorInstallation.spec.version`,
read when the Job is created and recorded on the Job in an annotation. The CR says what should be installed and
who may change it (it is in Git, per the design); the operation is the record of one execution. A target
version in both places would be two places to disagree. Consequence: changing `spec.version` while an
operation runs is detected (the annotation differs) and pauses the operation instead of silently finishing the
old target.

**The backbone is not rebuilt.** ADR 0026 calls `AdvanceMachineFunc` once per machine and an Ichor upgrade has
no machines. The executor is therefore idempotent per operation: the Job is named from a hash of the
operation's namespace/name, the first call creates it, every later call (whichever machine, whichever
reconcile, including the new controller after the upgrade restarted this one) looks at the same Job. Machines
complete serially as soon as the Job has. `acquireDisruptiveLock` remains the only `Acquire` caller. Known
limit: a cluster with no approved machine completes the operation vacuously without launching anything; Ichor
manages Talos clusters, so this is the empty case, and the operation's cluster lock is held for the Job's whole
run, which is the point.

**2. Where the target's manifest comes from.** From the target version's own installer image. `ichor installer
run` (a hidden subcommand) applies `installmanifest.Manifest`, the same embedded bytes `ichor init` applies
(ADR 0019), so the image is built with `-tags installembed` (`Dockerfile.installer`, `make
docker-build-installer`) and **fetches nothing at run time**. The
Job runs `<installerRepository>:<version>`. The controller never reads the new manifest; it cannot even tell
what it contains. The cost: one more image per release, and the manifest's own image references (controller,
UI) are pulled as usual.

**3. Snapshot and rollback.** Before any write, the installer saves the live state of every object the
manifest names (and a marker for those that do not exist yet) as gzipped JSON, split into 512 KiB parts, in
ConfigMaps `<run id>-snapshot-N` in the Job's namespace, labelled with the run id. ConfigMaps, not Secrets:
the snapshot holds no Secret by construction (a Secret in the manifest is neither applied nor snapshotted; the
installer's RBAC has no `secrets` rule at all). 512 KiB leaves wide margin under the 1 MiB object limit;
the seven CRDs gzip to a few hundred KiB, so normally it is one part, and an incomplete set of parts is refused
at load rather than trusted. A snapshot that already exists for the run id is reused, never retaken: a re-run
after a crash would otherwise snapshot the half-upgraded cluster over the good one. Rollback runs in reverse
order with `Update` (an exact restore, unlike re-applying, which would leave behind fields the new version
added), deletes objects the upgrade created, and **never deletes a CRD** (that deletes every custom resource of
the kind); a CRD that was new stays, and a CRD that existed is restored to its old schema. Snapshots are kept
after success or failure (evidence, and the manual way back when the rollback itself failed); garbage
collection of old snapshots is not built.

Order and checks: CRDs (each must be `Established`), then every non-UI object (Deployments must finish a
rollout: generation observed, all replicas updated and available), then the UI objects (same). UI objects are
recognised by `ichor-ui` in the name, because kustomize's prefix makes them `ichor-ichor-ui-*`.

**4. CRDs are additive.** Before the snapshot, for each CRD in the manifest that exists, the installer compares
the schemas and refuses if the target would drop a served version, drop a property, change a property's type
(recursively through properties, items and additionalProperties), or change the scope. This judges the schemas, not
a version string, so it also catches a target built from an older tree and a hand-edited live CRD. The refusal
comes before any write and leaves no snapshot. The controller-side `semver` comparison in the executor
(`spec.version` older than the running controller is refused, creating no Job) is a second, earlier line.

**5. `external` mode.** The executor does not launch the Job: it returns an error naming the pull request path,
which pauses the operation (ADR 0026). **Not built: opening that PR.** PR submission (repository connection,
token, the Argo CD Application's source path) is wired inside `internal/uiapi` (ADR 0056), which another unit
was editing; reaching it from the CLI means either a UI service for `IchorInstallation` or an export from
`uiapi`, to be done after that unit lands. Until then `external` mode means "bump the chart version in Git by
hand". The chart's installer RBAC is also off in that mode (below).

**6. CLI refuses writes beyond one minor.** The CLI compares its own `internal/version` with
`IchorInstallation.status.installedVersion` (the controller's report, so it works with or without ichor-ui,
including the `--local` fallback) before a command marked as a write: `machine approve`, `machine reject`,
`patch create/update/delete`. Reads are never refused. It refuses only on a positive finding: no
`IchorInstallation`, no reported version, an unreadable cluster, a `dev` build or different majors mean "cannot
judge" and the command proceeds. Not marked yet: `init`, `adopt`, `restore`, `gitops` (they run before the
server exists or against its absence; revisit when `ichor self-upgrade` lands). `ichor init` now creates the
`IchorInstallation` with its own version (skipped for a `dev` build).

**7. UI response header: not built.** Nothing sends or reads it; the version badge (`ui/src/lib/ichorVersion.ts`)
is what exists. It was to work like this: the backend sends `X-Ichor-Version` on every response from `internal/version`; the SPA compares it with
the version baked into its bundle and prompts a reload when they differ.

## The installer's RBAC, and how far it narrows

A separate ServiceAccount `ichor-installer` (`config/rbac/ichor-installer`, hand-written, compared with the
chart's copy by `dist/chart/chart_test.go`). The controller holds only `jobs: get, create` in its namespace; it
never holds the installer's permissions. The chart creates the identity only with `installer.enabled`
(default false: a chart-installed Ichor is normally Argo CD's, and nothing there should carry it).

Narrowed to exactly the kinds `dist/install.yaml` contains: CRDs `create`/`get` and `patch`/`update` limited by
`resourceNames` to Ichor's own seven; the `ichor-system` namespace `get`/`patch`/`update` by name;
Deployments, Services, ServiceAccounts, Roles and RoleBindings in the namespace only; ConfigMaps `create`/`get`/`list`
(the snapshots); ValidatingWebhookConfigurations and admission policies; no Secrets, no wildcards, no custom
resources, no CRD delete (a test fails the build if a `secrets` or `*` grant appears).

**Not narrowable: `clusterroles`/`clusterrolebindings` with `escalate` and `bind`** (and `roles` with the same in
the namespace). Applying a role that grants more than the applier holds is refused without `escalate`, and a
new version may add roles with names nobody can list in advance, so `resourceNames` cannot do it. This part is
as strong as cluster-admin. What contains it: the identity exists for one Job, only the controller can start
that Job, its image is the one `spec.installerRepository` names, and in `external` mode it does not exist.
Mirroring the installer image into a registry you control is what makes that last point hold. The installer
rewrites its own role during an upgrade (it is part of the manifest); a new version that narrows it takes effect
mid-run, which the rollback uses the old snapshot to undo.

## Built, and not

(The `external` pull request, the version label, snapshot garbage collection and the Job TTL listed as not built
below were built later; see "Amendment (the remaining items, built)".)

1. `IchorInstallation` CRD and reconciler: built. 2. `kind: ichor` executor and the Job: built. 3. The
installer (snapshot, additive check, ordered apply, post-checks, rollback), its image and RBAC: built, tested
against a real API server. 4. CLI skew refusal: built; UI header: **not started** (above). 5. `external`: no Job
launched, built and tested; opening the PR: **not started** (above).

Also not built: `ichor self-upgrade` (creates the operation and, in external mode, the PR) -- today the
operation is written by hand like any TalosOperation; `ichor self-upgrade --local` as the way out of a broken
controller; snapshot garbage collection; `ttlSecondsAfterFinished` on the Job (a finished Job and its snapshot
stay until deleted). Not verified: a real upgrade between two released versions, because there is no second
released version yet and no cluster to run it on.

## Amendment (the remaining items, built)

1. **Installer Job cleanup.** The Job carries `ttlSecondsAfterFinished: 86400` (one day: long enough to read the
   pod log of a failed upgrade, short enough that Jobs do not pile up). The operation does not need the Job once
   it has finished: after a successful upgrade the new controller's version equals `spec.version`, which the
   executor checks before it looks for a Job (decision 1, "Done must be decided before the mode"). An expired
   *failed* Job that is resumed after more than a day starts a fresh attempt under the same run id, which reuses
   the run's snapshot (decision 3), so it is a retry and never a second snapshot of a half-upgraded cluster.
2. **Snapshot garbage collection.** Each snapshot ConfigMap carries a `...-taken` annotation (RFC 3339, nanoseconds;
   `creationTimestamp` has one-second resolution and runs can share a second). After the installer has saved
   (or reused) its snapshot, and before it writes anything, it deletes every snapshot except the newest
   `SnapshotsKept` (3, the current one included) and never the current run's. Failing to delete is logged and does
   not fail the upgrade. The installer's Role gains `delete` on ConfigMaps (still namespace-scoped; the chart copy
   is compared by `dist/chart/chart_test.go`). The decision above that snapshots are kept after success or
   failure still holds, now for the last three.
3. **Version in the UI.** `OperationService.GetIchorInstallation` returns the installed version
   (`status.installedVersion`), `spec.version`, the mode, whether an `ichor` operation is unfinished, and
   `update_available`: `spec.version` is semver-newer than the installed version and no Ichor upgrade is running. A
   cluster without an `IchorInstallation` answers with empty fields, not an error. The app shell shows the
   installed version as a badge in the top bar, plus an "Update available" badge only when the server says so; a
   failed read shows nothing. The `X-Ichor-Version` response header (decision 7, a reload prompt when the bundle
   and backend differ) is still not built (decision 7 says so too); this label (`ui/src/lib/ichorVersion.ts`) is
   separate from it.
4. **`external` mode and Git: a pull request.** What the PR changes is the one thing prmode can edit safely:
   the `IchorInstallation` document in the file the tracking Argo CD Application's plain path source holds,
   field `spec.version` (target `IchorInstallation/ichor`, branch `ichor/upgrade-ichor-<version>`, so asking
   twice finds the first PR). It goes through the same gate as every other write (`planWriteIn`: Argo CD tracks
   it, the cluster's `gitops.repository` is the Application's repository, the source is a plain path). When the
   `CreateOperation`/`PlanOperation` target would change a tracked `IchorInstallation` and a plan exists, the
   backend opens the PR and returns `pull_request_url` with **no operation** (`opens_pull_request` in the plan, so
   the CLI and UI say so before they act); nothing is written to the cluster. When no PR can be opened the old
   refusal stands. In `ichor` mode the next step after merging is to start the upgrade again without a target (the
   installer Job runs). In `external` mode no Job ever runs, so the PR records the target only: the chart or
   manifests Argo CD applies for Ichor (a Helm source, which prmode cannot edit) must be bumped by hand, and the PR
   description says so. That is the conservative reading of "bump the chart version in Git"; editing a Helm
   `targetRevision` or values file is not attempted. An `external` installation that Argo CD does not track is
   refused by the plan (it can neither be written nor proposed). The executor in `external` mode still
   launches no Job and pauses, unchanged.

## Amendment (real hardware)

The executor never created the installer Job: it reads Jobs through the manager's cached client, whose cluster-wide
informer needs `list`/`watch` on `jobs.batch` at cluster scope, while the Role grants `jobs` in `ichor-system` only
(`jobs.batch is forbidden ... at the cluster scope`). The Job cache is now confined to the controller's namespace like
the Secret cache, and the Role grants `get;list;watch;create` on `jobs`. A new envtest spec (ADR 0081, item 6) builds
the manager's cache as the controller's identity bound to the generated RBAC and requires an informer to sync for every
kind read through the cached client, so this class of mismatch fails in CI.

Addendum (ADR 0088): an Ichor upgrade whose renderer emits different output is applied to nodes like any desired-state change (render.Version is part of the input hash); reboot-requiring changes wait for a config rollout.

## Amendment (self-upgrade from an older release)

An upgrade runs the installer Job with the RBAC of the release being replaced, so a rule the new release adds helps only the release after next. On the demo, `ichor upgrade ichor` failed with `cannot patch resource "customresourcedefinitions"` for `ichorusers.ichor.maesh.dev` (missing from the ClusterRole resourceNames) and `cannot delete resource "configmaps"` (snapshot garbage collection), and rolled back.

- The installer applies its own RBAC first. The ClusterRole, ClusterRoleBinding, Role and RoleBinding named `ichor-installer-*` form a step before the CRDs, in the same run. It may already `escalate`/`bind` and patch roles, so it grants itself the new rights, then applies everything else. The snapshot covers these objects too (first entry, so a rollback restores them last and still holds the rights it needs). Kubernetes evaluates RBAC per request and the installer client caches no authorization state, so no restart is needed; applies retry briefly on Forbidden (default 30s) because the authorizer cache can lag the write.
- Snapshot garbage collection now runs after the RBAC step and stays non-fatal. Its `delete` on ConfigMaps is namespace-wide in the release namespace: snapshot names derive from the generated Job name, so `resourceNames` cannot list them.
- Every CRD in `config/crd/bases` must be in the installer ClusterRole `patch`/`update` resourceNames. `dist/chart` tests (`TestInstallerRBAC`) fail when one is missing, in both the kustomize role and the chart template.

Amendment: a rolled-back upgrade leaves `spec.version` at the failed target, so "current" in plans and the CLI is `status.installedVersion` (the running controller's own version), never `spec.version`; `UpToDate=False` states running versus target.

## Amendment (an upgrade keeps what `ichor init` customized)

On the demo, installed with `--ui-url` and `--ui-tls-cert/key`, `ichor upgrade ichor` failed on `Deployment ichor-ichor-ui ... livenessProbe.tcpSocket: Forbidden: may not specify more than 1 handler type` and rolled back. `init` edits the manifest's Deployments and Service after installing them (users-enabled and issuer flags, the internal port and OIDC Secret volumes, the TLS Secret mount and flags, HTTPS probes, the controller's `--user-issuer`). The installer applied the bare manifest by server-side apply under its own field manager, so: container `args` (an atomic list) were replaced, silently dropping every init flag (users and TLS off, and the controller's issuer off); `ports`, `volumes`, `volumeMounts` and Service `ports` (merge lists owned by init's manager) survived by luck; probes merged field by field, leaving init's `httpGet` beside the manifest's `tcpSocket`.

- The customizations are one set of pure functions in `internal/installer` (`CustomizeUI`, `CustomizeController`, `CustomizeService`, over `UISettings`). `ichor init` calls them on the live objects; the installer calls them on the new manifest's objects before it classifies, snapshots or applies anything. They replace whole probes and are idempotent, so there is nothing to drift between the two.
- The settings are not stored anywhere new: the installer re-derives them on every run from the live Deployments (`ReadUISettings`): `--external-url`, `--kube-api-server-url`, `--kube-api-tls-server-name` and the `tls` volume's Secret on ichor-ui, `--oidc-refresh-lifetime` on the controller. Those are what init wrote and what an operator would see. A first install by the installer reads as a plain install.
- Deployments are written by a full `Update` (with the live resourceVersion and annotations carried over, field manager `ichor-installer`) instead of server-side apply, so a field another manager owns can no longer merge into the new spec. The Service and everything else keep server-side apply. The rollback snapshot is untouched: it records the live object before the run and writes it back whole.
- A rolled-back Ichor upgrade leaves a Paused `TalosOperation` with a failure reason and `spec.version` at the failed target. A new `ichor upgrade ichor` is refused while it exists (one unfinished operation per cluster); resuming it only meets the failed installer Job again, which is kept for a day. The refusal now says to run `ichor operation delete NAME` and then `ichor upgrade ichor <version>`; after that, any version at or above the running one is accepted, the failed one included.

## Amendment (the installer snapshots its own RBAC first, then everything else)

- Bug: the snapshot read every object of the new manifest before the installer applied its own RBAC. An older release's `ichor-installer-role` has no rule for a kind that is new in the release (a CiliumNetworkPolicy, for the upgrade from v0.0.1797000000-82b4420), so the snapshot failed Forbidden before anything was applied and every new kind blocked every upgrade from an older release.
- Order now: (1) snapshot stage 0, only the `ichor-installer-*` ClusterRole, ClusterRoleBinding, Role and RoleBinding; (2) apply them; (3) poll a SelfSubjectAccessReview for the first rule of each applied role (verb `get` when the rule has it) until allowed, bounded by `RBACSettle` (default 30s), because RBAC is eventually consistent; (4) snapshot stage 1, everything else (plus the Cilium policies); (5) CRDs, controller, UI, network policies as before. Both stages carry one run id (a stage annotation on the ConfigMaps); a rollback reads them as one list and restores stage 1 first, the RBAC last, so it still holds the rights it needs. A re-run keeps a stage that already exists.
- (Corrected by "Amendment (installer rights are cluster-scoped only)" below: this holds for the installer's own ClusterRole and the Role in the release namespace, not for a new namespaced Role elsewhere.) No bridge is needed for those. Every release since the installer role was introduced (ffb1413) grants the role get/create/patch/update/delete on `clusterroles`, `clusterrolebindings`, `roles` and `rolebindings` plus `escalate` and `bind` on `clusterroles` and `roles`, so the oldest role can rewrite itself without the user's rights. The CLI does not pre-apply anything.
- The snapshot treats NotFound, and a kind whose CRD is not installed (no REST mapping), as "did not exist": nothing to restore, and the rollback skips it. Forbidden or any other error fails the run before it writes, never ignored, because the rollback depends on the snapshot being complete.

## Amendment (installer rights are cluster-scoped only)

- Bug (hardware): an upgrade from an older release failed at the stage-0 snapshot with `roles.rbac.authorization.k8s.io "ichor-installer-kube-system-role" is forbidden ... in the namespace "kube-system"`. Commit 8a7199b moved the Cilium Job to kube-system and gave the installer a namespaced Role there. The old installer role holds no rights in kube-system, so it can neither read nor create that Role, and "RBAC first" cannot bootstrap a namespaced Role in a namespace the old role never reached. The "no bridge is needed" claim above is wrong for any new namespaced RBAC outside the release namespace.
- Rule: installer rights are cluster-scoped only. The installer holds no namespaced Role outside the release namespace. What `installer-kube-system-role` granted (serviceaccounts, roles and rolebindings: create/delete/get/patch/update, and bind/escalate on roles) is folded into `installer-role`'s ClusterRole, which every release's installer can rewrite: since ffb1413 the role holds get/create/patch/update on clusterroles and clusterrolebindings plus bind and escalate on clusterroles. `installer-kube-system-role`, its RoleBinding, their chart copies and the kustomize patches are removed. The controller's `cni-job-role` in kube-system stays; the installer creates it, and the folded rules (with escalate and bind on roles) let it.
- Order: stage 0 is now the installer's ClusterRoles and ClusterRoleBindings only (snapshot, apply, wait until effective); stage 1 is its namespaced Roles and RoleBindings (snapshot, apply, wait); stage 2 is everything else. A future namespaced installer Role elsewhere is read under the already-widened ClusterRole. Rollback restores stage 2, then 1, then 0.
- Cost: the installer's serviceaccount, role and rolebinding rights now hold in every namespace, not only kube-system. The ClusterRole already held escalate and bind on clusterroles, so it was already as strong as cluster-admin (see "The installer's RBAC").
- Amendment (ADR 0113): an Ichor upgrade does not move the cluster's Talos, Kubernetes or Cilium versions. Each Ichor build embeds its own release set, and the cluster follows it through `ichor upgrade cluster`, one step at a time, after the Ichor upgrade has finished.

## Amendment (the operation completes only when the rollout is over)

- Bug (UI demo): `ichor upgrade ichor --follow` reported the operation Completed while the installer Job was still Running and ichor-ui rolled out 10 to 40 seconds later. The new controller resumes the operation as soon as it runs, and the executor returned done as soon as `spec.version` equalled the running version, without looking at the Job.
- Rule: an `ichor` operation is Completed only when (1) the installer Job has succeeded (or is gone: it is removed after a day), (2) the controller Deployment (`control-plane=controller-manager`) and (3) the ichor-ui Deployment (`app.kubernetes.io/component=ui`) have finished rolling out: `observedGeneration` is current and the updated, ready and available replica counts equal the desired count with no old replica left. A failed Job fails the operation with the Job's message, even when the new controller already runs. A Deployment that is not installed is not waited for. The check is the same whichever controller runs it (the old one after the Job succeeds, the new one on resume).
- The Deployments are read through the uncached API reader (`get` and `list` in the release namespace, already granted), not the cached client: a cached read would start a cluster-wide informer (ADR 0081). The CLI `--follow` only polls the operation phase, so it needs no change.

## Amendment (an operation shows what it is waiting for)

- Gap (UI demo): the executor's waiting text ("the installer Job is running", "waiting for the ichor-ui Deployment to finish rolling out (n of m updated replicas are available)") was built on every reconcile and thrown away, so nobody could see what a running operation waited for.
- Rule: `TalosOperation.status.message` (optional, appended) holds the message the controller last passed to `setPhase` while the phase is InProgress. It is replaced when it changes (an unchanged value is a no-op status update) and cleared in every other phase: a pause says why in `failureReason` or the Paused condition, a finished operation in its Events. It applies to every kind that goes through `setPhase`, not only `ichor`.
- Shown by `Operation.message` (proto field 21, `GetOperation` and the list), by `ichor ... --follow` and `operation get --watch` (the progress line ends in `: <message>`, printed whenever the line changes), and by the "Currently" field on the operation detail page. The operations list is a table and does not show it.
