# ADR 10 — Kubernetes version axis<no value>

<!-- Generated by website/sync-docs.py from docs/adr/0010-kubernetes-version-axis.md. Edit that file, not this one. -->

Status: accepted

Amends decisions 3, 4 and 5, and adds decision 16.

## Context

Corium ships one Kubernetes version per OS image. `build/k0s.lock` pins a release
and two checksums. `build/scripts/install-k0s.sh` fetches that release into
`/usr/bin/k0s` at build time. Decision 3 states the rule that makes this
deliberate, not accidental: the image owns the binary, an upgrade replaces it
completely, and nothing writes it at runtime. Decision 4 follows from it. A
Kubernetes upgrade is a new image, `bootc upgrade`, a reboot, and an atomic
rollback if the upgrade goes wrong. The result is one upgrade mechanism, one
version axis, and free rollback.

That is a good trade for the OS. It is a bad trade for Kubernetes, because the
two change for different reasons and at different rates.

An operator on Corium 0.3.6 runs Kubernetes 1.36.4, because 0.3.6 contains that
version. To get 1.37, the operator waits for a Corium release that carries it.
To get a Fedora security fix, the operator takes the Kubernetes minor that this
Corium release carries, whether or not the workloads are ready for it. Today,
neither direction can be separated, and both are ordinary requests. The coupling
also affects the project. Every Kubernetes minor that Corium wants to offer is a
Corium release. Every Corium release is a statement about a Kubernetes version
that Corium did not intend to make.

Decision 5 makes this worse. k0s Autopilot is the upstream answer to in-place
Kubernetes upgrades. It is disabled, correctly, because it mutates the k0s binary
in place, and decision 3 says that this binary is image content. So Corium
disables the mechanism and does not replace it. An operator who wants a
different Kubernetes version on a running node has two options left. The first
is `cctl reset`, which removes the node from its cluster. The second is a full OS
rebase to an image that the operator possibly does not want for any other
reason.

### What Talos does, and why it does not transpose

Talos separates `talosctl upgrade` from `talosctl upgrade-k8s`. The OS image
contains no Kubernetes version at all. The control plane runs as static pods
from registry images. The kubelet is a container image named per node. An
upgrade rewrites image tags in the machine configuration. A support matrix sets
which Kubernetes versions each Talos release accepts.

The shape is right, but the mechanism does not transfer. k0s is a single
self-extracting executable: a binary with a ZIP payload appended. The payload
carries kubelet, containerd, runc, the API server, scheduler, controller-manager,
etcd and the CNI plugins. On start, k0s stages these contents into
`/var/lib/k0s/bin` (`pkg/assets/stage.go`). There is no set of image tags to
rewrite. To decouple the Kubernetes version from the OS version is to decouple
*that file* from the OS image, and nothing else.

### What the platform offers

- **systemd system extensions** merge `/usr` and `/opt` with an overlayfs. The
  man page explicitly names immutable system images as the use case. The merged
  `/usr` stays read-only. systemd searches for extensions in `/etc/extensions/`,
  `/run/extensions/` and `/var/lib/extensions/`. systemd documents the last one
  as the primary install location. On an OSTree system, it is also the only
  writable one. `systemd-sysext.service` is ordered before `basic.target`. So an
  ordinary unit such as `k0scontroller.service` sees the merged tree, and Corium
  does no extra work for it.
  ([systemd-sysext(8)](https://man7.org/linux/man-pages/man8/systemd-sysext.8.html))
- **Each extension declares what it fits.** `/usr/lib/extension-release.d/extension-release.<NAME>`
  must match the `ID` of the host. If the extension sets `SYSEXT_LEVEL`, that
  value must match. If not, `VERSION_ID` must match. This is the sharpest edge in
  the whole design, and the decision below treats it as such.
- **The pattern is established, but not for k0s.** The Fedora image-mode sysext
  project publishes `kubernetes-1.33` through `kubernetes-1.36`. The Flatcar
  sysext-bakery publishes Kubernetes, containerd and Docker extensions with
  `systemd-sysupdate` metadata. Flatcar states plainly that it supports updates
  within a minor and never across one. Neither catalog contains k0s. So Corium
  cannot consume this pattern. It must produce it.
  ([fedora-sysexts](https://fedora-sysexts.github.io/fedora/),
  [sysext-bakery](https://flatcar.github.io/sysext-bakery/kubernetes/))
- **k0s supports the swap.** `k0s stop`, replace the binary, `k0s start` is the
  documented upgrade path without Autopilot. The unit that `k0s install` writes
  puts the absolute path of the binary into `ExecStart`. Usually, that makes a
  swap a reinstall. But here the path does not change, because an extension
  overlays `/usr/bin/k0s` and does not install a replacement at another path.
  ([k0s upgrade](https://docs.k0sproject.io/head/upgrade/))
- **k0s's version skew rules are stricter than upstream Kubernetes'.**
  Controllers must be within one minor of each other. A worker can be one minor
  older than the controllers, and never newer. Upgrades go one minor at a time,
  controllers first. At bootstrap, a worker must match its controllers exactly.
  ([version skew policy](https://docs.k0sproject.io/head/version-skew-policy/))

## Decision

**`/usr/bin/k0s` stops being image content and becomes a slot.** The path does
not change, and neither does any caller of it: `internal/k0s.Binary` keeps its
value. What changes is who fills the slot.

### 1. The image still ships a k0s, and an extension overlays it

The OS image continues to install a pinned k0s at `/usr/bin/k0s`, exactly as it
does today. That binary is the **floor**: the version that a node runs when
nobody told it a version, when it cannot reach a mirror, or when its extension
stopped matching.

This is the most important part of the decision, and it is plain on purpose. To
ship no k0s at all is cleaner on paper. But then first-boot network access to a
mirror becomes necessary for a machine to become a node. The sharpest failure
mode (an extension that does not load) then gives a dead node, not a degraded
one. It also makes this change breaking for every node already in service. The
floor costs the image size of one binary. In return, a node always boots into
*some* Kubernetes.

### 2. Extensions are signed OCI artefacts, pulled and verified like the OS

Corium publishes each k0s system extension as a signed OCI artifact, referenced
by digest. The existing container signing policy of the node is the gate. It is
the same policy that `internal/upgrade/policy.go` already applies before it lets
`bootc` move a node to another OS image: an artifact is acceptable only if
`/etc/containers/policy.json` demands a signature for it.

This is reuse, not symmetry for its own sake. Corium already refuses to move a
node onto an unsigned OS image. A file that becomes the kubelet and the API
server on that node deserves the same refusal. A bare URL and a checksum give
integrity without provenance. They protect against corruption in transit, but
not against the source of the artifact. `build/k0s.lock` already draws that
distinction in its own header.

**Amended 2026-09-26: `skopeo` ships in the image.** When this record was
written, the promise above had no mechanism behind it. A Corium node carries no
container engine, and the `Containerfile` says so on purpose. So nothing on the
node was able to pull an artifact. Nothing was able to apply `policy.json` either, because
`policy.json` is not a file that the node interprets. It is a contract that the
`containers/image` library implements. Without code that reads it, a promise of
verification "by the same policy.json" made this section only an aspiration.

`skopeo` is the command line of that library, and this amendment adds it to the
image for that purpose. Be precise about what it is not. It copies images
between registries and local layouts, and it runs nothing. The `Containerfile`
objects to a container engine because a second engine fights k0s for
cgroups and CNI state. That objection does not apply to `skopeo`. It has no
daemon, no runtime, and nothing that starts at boot.

The alternative was to write a registry client and sigstore verification in Go.
Decision 13 is the argument against it: signature verification is exactly the
class of format whose hand-rolled implementation "is wrong in ways nobody
notices until a node trusts a key it should not". This is the shape of exception
that the rule allows, and it is a package in an image, not an entry in `go.mod`.

### 3. A new `corium.kubernetes:` block

```yaml
corium:
  kubernetes:
    version: v1.36.4+k0s.0
    mirror: registry.internal.example/corium/k0s
```

Both fields are optional, as the twenty-line constraint of decision 1 requires.
If `version` is not set, the node runs the floor. `mirror` redirects the pull,
and this is how an air-gapped site is served. Point `mirror` at an internal
registry, instead of asking Corium to invent an offline mode.

Validation is offline, per AGENTS §7. It checks the syntax of the version, its
presence in the support window that the image was built with, and the skew
rules, all without a network call. The pull answers whether the artifact exists.

### 4. The support window is three minors

An image supports the Kubernetes minor that it ships as its floor and the two
adjacent minors. That is three in total, published as a matrix and stated in the
docs. The number three comes from the skew rules of k0s, not from taste. The
rules are: one minor at a time, controllers first, and workers no newer than
controllers. So a cluster in the middle of a rollout legitimately spans two
minors. A third minor gives an operator somewhere to go, without first taking an
OS upgrade that the operator did not ask for.

The window is a property of the OS image. To move outside it, an operator still
needs a `bootc` upgrade. The two axes are independent, but they are not
unbounded.

### 5. Matching is by `SYSEXT_LEVEL`, which Corium owns

An extension that pins `VERSION_ID` binds itself to a Fedora major.
`bootc upgrade` does not touch `/var`, by design. So an extension in
`/var/lib/extensions` **survives an OS rebase and then silently stops
matching**. A node that rebases from Fedora 44 to 45 comes back on the floor
version, or on nothing, with no warning. The Fedora sysext project lists this as
a known issue.

So extensions declare a `SYSEXT_LEVEL` that Corium defines and versions on
purpose. The cost is real, and this record accepts it. Corium takes ownership of
an ABI contract between its OS images and its k0s extensions. A bump of that
level is a reviewable event, in the same way that a change to `build/k0s.lock`
is today.

### 6. Day two: `kubernetes.version` joins ADR 8's safe subset, behind a skew gate

ADR 8 established that a bootstrapped node re-applies the fields whose reconciler
Corium already delegates to, and refuses everything that defines what the node
is. `kubernetes.version` joins `addons` and `k0s.patch` in the safe subset. It
does not define what the node *is*. The node stays the same machine, in the same
cluster, under the same name and address. It only runs a different build of the
software that serves it.

It carries a constraint that the other two fields do not carry, and
`PlanReconcile` enforces it. If a change violates the skew rules of k0s,
`PlanReconcile` **refuses it and names the rule**. It does not pass the change to
k0s to discover at restart. Skipping a minor is the error that operators
actually make, and the moment to report it is before anything moves.

A version change is a bounded sequence that the node performs on itself: drain,
`k0s stop`, swap the active extension, **clear `/var/lib/k0s/bin`**, `k0s start`,
uncordon. The purge is not tidiness. k0s skips re-staging its embedded payload
when the mtime of the already-staged file equals the mtime of the k0s executable
and its size matches. An EROFS extension built reproducibly has a normalized
mtime. So two versions can collide, and a node can run a stale kubelet against a
new control plane. The purge removes this possibility, so nobody has to reason
about when it can happen.

### 7. Ordering a rollout stays with the operator

`cctl` gets the verb. The daemon does not get a rollout. A node knows only about
itself, and "controllers first, one at a time, then workers" is a statement
about the other nodes. This is exactly the division that decision 11 and ADR 4
draw, and the one that `internal/cctl/rollout.go` already implements for OS
upgrades.

### 8. Autopilot stays disabled

This record does not weaken decision 5. Decision 5 is what makes this record
coherent. Autopilot was rejected because it mutates the binary in place, without
the knowledge of the operator. Corium now offers the capability that Autopilot
was wanted for, a Kubernetes version change without a reset, as an explicit,
operator-triggered, signed, drain-aware act. The objection was never to
in-place upgrades. It was to a controller that performs them without being
asked.

## Consequences

**Decision 4 is split, and must be read as two.** OS upgrades stay image-based
with atomic rollback, and they do not change. Kubernetes upgrades become their
own axis with their own verb. "One upgrade mechanism, one version axis, free
rollback" was worth stating while it was true. This record is where it stops
being true. Rollback of a Kubernetes version is no longer free. To revert to the
previous extension is a second forward operation, and k0s does not support a
downgrade of a minor at all. The docs must say this plainly, and not leave
operators to discover it.

**Decision 3 is amended in letter, kept in spirit.** The binary is no longer
*image* content. But it is still read-only content from a signed, digest-pinned
artifact that nothing on the node writes to. Corium does not trade
"Immutability is the product" for a mutable `/usr/bin/k0s`. It extends that rule
to cover a second immutable source.

**Corium becomes a publisher of k0s artifacts.** Nobody publishes k0s system
extensions, so Corium builds, signs and hosts them. This is a new supply-chain
surface and new release work: three minors, two architectures, signed and
published on their own schedule. It is the largest single cost in this record.
It also means that `build/k0s.lock` stops being a file about one version and
becomes a file about a window.

**Two version axes are two things to explain.** A support matrix must exist, be
published, and be correct. `cctl` must answer "what Kubernetes am I running, and
what can I move to", so that an operator does not have to cross-reference a
table. The twenty-line constraint holds, because both new fields are optional.
But the number of concepts went up by one. That is a real cost to a project
whose stated goal is that a node is describable in twenty lines.

**A new failure mode needs to be visible.** An extension that stops matching
does not fail loudly. It does not merge, and the node comes back on the floor
version. Greenboot must check that the running k0s version is the declared one,
and fail the boot if it is not. Otherwise, the quietest error of the design
becomes its most common support question.

**First boot can now depend on the network.** A node that declares a non-floor
version must reach its mirror. The floor keeps this from being fatal. But a node
that boots into 1.36 when its configuration said 1.37 did not do what it was
asked. It must say so, and not appear healthy.

## Alternatives considered

**Download the binary into `/var` at bootstrap.** This is the obvious
implementation, and the reason to write this record instead. It puts a mutable,
unsigned file on the most critical path of the node. It gives up the read-only
guarantee for the one binary that needs it most. It also reimplements, badly,
the verification that `policy.json` and the sysext machinery already provide. It
is cheap to build and expensive to own.

**Build image variants: `corium:0.4.0-k8s1.33`, `-k8s1.34`, …** This keeps
decisions 3 and 4 completely, which is its appeal. It is rejected because it
does not actually decouple the axes. It multiplies them. The OS × Kubernetes
matrix applies to every build, every signature, every publish and every test. An
operator who changes only the Kubernetes minor still needs a full OS rebase and
a reboot to do it. This option moves the coupling into the tag and calls it a
separation.

**Ship several k0s binaries inside `/usr` and select one at boot.** This keeps
every existing guarantee and needs no network at all, which is a real advantage.
It is rejected for two reasons. First, the image still bounds the window. To
reach a fourth minor is still an OS upgrade, so the coupling is looser but not
broken. Second, three self-extracting k0s binaries are a large dead weight in
every image, on every node, and most of that weight never runs. It is the floor
idea taken to three floors, and the third floor does not pay for itself.

**bootc logically bound images.** This is the mechanism built to pin content to
an OS image, so it deserves serious consideration. It is rejected on a fact.
Logically bound images (LBIs) deliver podman containers, pulled at install and
upgrade time, with no mechanism to put a file in `/usr/bin`. To reach the
binary, the node must run a container that copies its contents onto the host.
That is a sysext with extra steps and no read-only guarantee. The ecosystem is
also not yet stable. Anaconda does not support LBIs, and image-builder has open
issues where bound images fail against the default store of podman.

**Enable k0s Autopilot.** This is the upstream answer, and it is available
today. It closes the capability gap, and Corium does not have to publish
anything. It is rejected for the reason that decision 5 gives and this record
keeps. It mutates `/usr/bin/k0s` on a running node from a controller in the
cluster. That violates the read-only contract, and it is a fleet-management
posture that decision 11 declines. A cluster resource that rewrites the
operating system is the wrong direction of control.

**Status quo.** The status quo stayed in place too long, but it was defensible.
One axis is truly simpler. A project below 1.0 with a small support surface can
credibly say "take the image that has the Kubernetes you want". It stops being
defensible when Corium has operators who need a Fedora security fix without a
Kubernetes minor bump, or a Kubernetes minor bump without a Fedora rebase. Both
needs are ordinary, and the image axis can express neither.
