# 0022. pkg/upgrade: version checks machinery already answers, and the ones it doesn't

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

Decided: Delegate the Talos/Kubernetes compatibility matrix to `pkg/machinery/compatibility` instead of reimplementing it

## Context

Design section "Upgrades" lists, among the per-node pre-checks for a Talos upgrade, "the
supported upgrade path and the Kubernetes pairing," and separately requires Kubernetes upgrades
to move "one minor at a time within the skew policy." It also names the first thing to evaluate
before writing any of this: "Reusing talosctl's `upgrade-k8s` logic is the first thing to
evaluate."

This ADR covers only the calculable half of that: given version strings, is a transition
supported. It does not cover the state machine, `TalosOperation`, or anything that drives an
actual upgrade against a running cluster — that is separate work, already underway elsewhere.
Two pre-checks genuinely need a running cluster (etcd health, node readiness); this package
declares them as interfaces and implements neither, on purpose.

## Evaluating `upgrade-k8s` reuse

`talosctl upgrade-k8s`'s logic lives in `github.com/siderolabs/talos/pkg/cluster/kubernetes`
(`upgrade.go`), which in turn is built on `github.com/siderolabs/go-kubernetes/kubernetes/upgrade`
— a separate, small, public Go module (not `internal/`), at `v0.2.41` as pinned by Talos v1.14.3's
own `go.mod`. It is genuinely reusable code, not internal-only, so the question is whether to
depend on it, and the answer is no, for two independent reasons:

- **Wrong shape.** `upgrade.Checks` (`checks.go`) takes a COSI `state.State`, a `*rest.Config` for
  the Kubernetes API, and node lists, and talks to both directly (`k8s.io/client-go/dynamic`,
  `k8s.io/apimachinery`). `pkg/upgrade` is specified to be a pure package — version strings in,
  an error out — so importing this would import a stateful, cluster-connected tool into a package
  whose whole point is not being one.
- **Wrong granularity, for the one piece that would otherwise fit.** `upgrade.Path.IsSupported()`
  (`path.go`) is pure (just a `switch` on `"major.minor->major.minor"` strings) and would have
  been the reusable part. But it hardcodes an explicit, flat list of every allowed pair from
  `1.19->1.19` through `1.37->1.37`, one entry per Kubernetes minor released since go-kubernetes
  started tracking this. Every entry is the identical "same minor, or exactly one minor forward"
  rule — there is no exception anywhere in the list — so depending on it would mean taking on a
  module that needs bumping just to recognize a Kubernetes release it doesn't enumerate yet,
  for a rule with no actual per-version content to reuse.

Confirmed by reading `path.go` directly (quoted above), not inferred from the package's existence.
`KubernetesSkew` (`kubernetes_skew.go`) reimplements the identical rule generically instead,
using `golang.org/x/mod/semver` (already an indirect dependency, now direct) rather than
`github.com/blang/semver/v4` (what `go-kubernetes` and `pkg/machinery/compatibility` both use) —
no reason to prefer one here, `x/mod/semver`'s string-in/string-out API needed less wrapping for
this one comparison.

## Decision

**The Talos upgrade path and the Kubernetes/Talos pairing are not reimplemented at all.**
`pkg/machinery/compatibility` (already a dependency, ADR 0003) carries both, per Talos minor
release, maintained by Talos itself:

- `TalosVersion.UpgradeableFrom(host *TalosVersion) error` — the actual rule, which is a minimum
  host version, a maximum host version for downgrades, and an explicit deny-list, **not** a
  generic "one minor at a time" rule. Confirmed by reading it: Talos 1.14's own compatibility
  data (`compatibility/talos114/talos114.go`) sets `MinimumHostUpgradeVersion = 1.12.0`, meaning
  `1.12 -> 1.14` is a supported upgrade that skips `1.13` entirely. `pkg/upgrade.TalosPath` is a
  thin wrapper: parse both versions with `compatibility.ParseTalosVersion`, call
  `to.UpgradeableFrom(from)`.
- `KubernetesVersion.SupportedWith(target *TalosVersion) error` — the Kubernetes/Talos pairing,
  same per-minor data (`talos114.MinimumKubernetesVersion` = `1.32.0`,
  `MaximumKubernetesVersion` = `1.37.99` for Talos 1.14). `pkg/upgrade.KubernetesPairing` wraps it
  the same way.

**The Kubernetes skew policy is reimplemented, generically, in `pkg/upgrade.KubernetesSkew`**:
same major version, minor moves by at most one step, never backwards. Sourced from
[kubernetes.io's version skew policy](https://kubernetes.io/releases/version-skew-policy/)
("the relative version skew between control plane components ... must differ by no more than one
minor version") and independently confirmed by `go-kubernetes`'s own hardcoded table (every pair
in it is exactly this rule, as established above) — two independent sources agreeing, not one
read taken on faith.

**The two cluster-dependent pre-checks (etcd health, node readiness) are not declared here at
all — not as an implementation, and not as an interface either.** An earlier draft of this ADR
declared `EtcdHealthChecker`/`NodeReadinessChecker` interface types with no implementation and no
caller, reasoning that the package should at least name the shape. That was wrong, caught in
review, and removed (the deleted `checks.go` is this paragraph's reason for existing). Go's own
convention is that interfaces are declared by the consumer, at the point of consumption ("accept
interfaces, return structs") — the consumer here is the upgrade state machine, which does not
exist yet. Declaring the interface anyway means guessing both its method set and where it
belongs before anything calls it: whoever writes that state machine will declare exactly the
methods they call, in their own package, and there is no reason to expect `EtcdHealthy(ctx)
error` matches what they actually need (per node or cluster-wide? a bool, an error, or a
structured result? synchronous or progress-reporting?). Deleting it costs nothing the design
still needs: "pre-checks cover etcd health ... and node readiness" stays exactly as said in
design section "Upgrades", unchanged by this ADR either way — what the paragraph above describes
is a requirement on the eventual state machine, not a Go declaration this package owed it.
`Impact`-style result aggregation (the shape `pkg/fieldmeta.Assess` uses) was also considered and
not added, for an unrelated reason: the three pure checks (`TalosPath`, `KubernetesPairing`,
`KubernetesSkew`) answer three genuinely different questions with different inputs (Talos-to-
Talos, Kubernetes-to-Talos, Kubernetes-to-Kubernetes), so there is no single changed-paths-style
list to aggregate over the way fieldmeta's field paths are; combining them into one call would
just be a tuple of three independent answers wearing a struct.

## Consequences

- `pkg/upgrade` has no version-compatibility data of its own to maintain. A Talos release that
  changes a supported path, a Kubernetes pairing, or (per Kubernetes' own policy, essentially
  never) the skew rule shows up the moment machinery is bumped, for the first two, with zero
  code change here.
- `golang.org/x/mod` moved from an indirect to a direct dependency (`go mod tidy`); no new module
  was added.
- Callers (the upgrade state machine, out of this change's scope) get three independent pure
  functions plus two interfaces, and decide for themselves how to sequence and aggregate them.
