# Cluster upgrade plan — k3s 1.31.5 → 1.36.x + node OS (dev cluster)
> ## ✅ COMPLETED / historical — executed 2026-08-30, see git log
>
> **This plan has been carried out.** The dev cluster now runs **k3s v1.36.4+k3s1** on **Debian 13
> (trixie)** with Cilium 1.20.1 and Longhorn 1.12.1. The steps, gates, and rollbacks below are
> retained as the record of how the upgrade was performed — do NOT re-execute them against the live
> cluster. Current state is tracked in `infra/PHASE1-STATUS.md` and the 2026-08-30 DECISIONS-LOG entry.
**Scope:** the `home-network-k3s-impl` **dev** cluster (`k3s-dev-1/2/3` @ 192.168.1.50-52), 3× HA
embedded-etcd servers behind a kube-vip VIP, Debian 12 bookworm. Upgrade Kubernetes/k3s from
**v1.31.5+k3s1 → v1.36.x+k3s1** (five minors) and the node OS (Debian 12 → 13), with an
etcd-snapshot / Velero / CNPG backup gate and explicit PASS/STOP criteria at **every** step, and a
defined rollback for each hop.
> Status: **PLAN ONLY.** Nothing here has been executed. All version pins marked "confirm at
> execution" must be re-checked against the upstream release page on the day of the hop — this doc
> was written 2026-08-29 and homelab component versions move weekly (Renovate).
> Companion runbooks that this plan reuses wholesale:
> - `docs/plans/ssd-etcd-execution-runbook.md` — the **proven safe-bounce** procedure (Longhorn
> rebuild-freeze + one-node-at-a-time + leader-last + per-node verify gates). Every k3s hop below
> is a parameterised repeat of that runbook.
> - `docs/plans/ssd-etcd-isolation.md` — why etcd is fragile on this cluster (slow HDD; etcd now on
> a dedicated SSD `virtio2`) and how a single member can be reset+rejoined from the other two.
---
## 0. TL;DR / operator decisions needed
| # | Decision | Recommendation | Needs operator sign-off |
|---|---|---|---|
| D1 | k3s upgrade **method** | Scripted **in-place per-node** binary upgrade driven by Ansible, one node at a time, leader-last, with the Longhorn rebuild-freeze. NOT system-upgrade-controller (its auto-drain fights this cluster's slow-HDD Longhorn and removes the human gate between nodes). | **Yes** — SUC vs scripted-in-place |
| D2 | Node **OS** upgrade | **Immutable re-provision** via tofu (bump the cloud image to Debian 13 trixie, replace one node at a time, rejoin etcd), run as a **separate campaign** from the k3s hops. Not in-place `apt` dist-upgrade. | **Yes** — Debian 13 now, or stay on 12 |
| D3 | Do OS + k3s on the **same** node bounce? | **No.** Never combine. Finish the k3s ladder to a stable target, then run the OS campaign (or vice-versa), one variable per bounce. | Confirm |
| D4 | Sequencing granularity | One k3s **minor** per maintenance window (5 windows). Cilium + Longhorn + Gateway-API CRDs advance in **lockstep** inside the same window (they are the pacing blockers). | Confirm |
| D5 | Flux `image.toolkit.fluxcd.io/v1beta2` → `/v1` migration | **Must** be done before Flux is upgraded to v2.7+ (which is required for k8s 1.36). Do it early, decoupled. | Confirm timing |
**Headline blockers (do these or the hop fails):**
1. **Cilium 1.16.5 is already at/near its ceiling** — Cilium 1.16 tops out around k8s 1.30-1.31.
Cilium **must** step 1.16→1.17→1.18→1.19→1.20 in lockstep with the k8s minors. It is the single
most urgent item.
2. **Longhorn 1.7.3** must step 1.7→1.8→1.9→1.10→1.11→1.12 (one minor at a time) to reach a
1.36-validated release (1.12.x).
3. **Flux v2.4.0 → v2.7+** is required for k8s 1.36, and v2.7 **removed** the image-automation
`v1beta2` API the repo still uses.
4. **KubeVirt v1.9.0 / CDI v1.66.0 are already 1.36-ready** — a pleasant surprise; no VM-stack
upgrade needed, just keep them where they are.
---
## 1. Confirmed current-state inventory
### 1.1 Nodes (live, verified 2026-08-29 via `dev.sh run kubectl get nodes -o wide`)
| Node | IP | Roles | k8s | OS | Kernel | Runtime |
|---|---|---|---|---|---|---|
| k3s-dev-1 | 192.168.1.50 | control-plane,etcd,master | v1.31.5+k3s1 | Debian 12 bookworm | 6.1.0-52-cloud | containerd 1.7.23-k3s2 |
| k3s-dev-2 | 192.168.1.51 | control-plane,etcd,master | v1.31.5+k3s1 | Debian 12 bookworm | 6.1.0-52-cloud | containerd 1.7.23-k3s2 |
| k3s-dev-3 | 192.168.1.52 | control-plane,etcd,master | v1.31.5+k3s1 | Debian 12 bookworm | 6.1.0-52-cloud | containerd 1.7.23-k3s2 |
- 3 servers, embedded etcd, **all** control-plane (no dedicated workers). Quorum = 2/3.
- kube-vip control-plane VIP **192.168.1.49** (fronted through pfSense NAT at 192.168.2.50:6443).
- etcd datastore is on the **dedicated SSD `virtio2` (`/dev/vdc`, LABEL=k3s-etcd)** mounted at
`/var/lib/rancher/k3s/server/db` (migrated this session). Longhorn data disk = `virtio1`
(`/dev/vdb`, `/var/lib/longhorn`). OS = `virtio0` (`/dev/vda`).
- **18** Longhorn volumes, all `healthy` (the count is the post-step invariant).
### 1.2 Where versions are pinned in this repo
| Thing | Pin location | Current value |
|---|---|---|
| **k3s version** | `infra/ansible/roles/k3s_server/defaults/main.yml` → `k3s_version`; applied via `INSTALL_K3S_VERSION` in `roles/k3s_server/tasks/main.yml` | `v1.31.5+k3s1` |
| k3s install guard | `roles/k3s_server/tasks/main.yml` — install task has `creates: /etc/rancher/k3s/k3s.yaml` | **idempotent → will NOT upgrade a running node on a version bump** (see §4) |
| kubectl/helm/flux (toolbox) | `infra/devcontainer/versions.env`, `ci/versions.env` | kubectl 1.31.4, flux 2.4.0 |
| **Cilium (ongoing)** | `platform/cilium/helmrelease.yaml` → `version: "1.16.5"` | 1.16.5 |
| Cilium (bootstrap) + **Gateway API CRDs** | `infra/ansible/roles/cilium/defaults/main.yml` → `gateway_api_version: "v1.2.1"`, `gateway_api_crd_urls` | v1.2.1 |
| **Longhorn** | `platform/longhorn/helmrelease.yaml` → `version: ">=1.7.0 <1.8.0"` (live 1.7.3) | 1.7.3 |
| **KubeVirt + CDI** | `platform/kubevirt/kustomization.yaml` (remote release manifests) | KubeVirt v1.9.0, CDI v1.66.0 |
| **kube-vip** | `infra/ansible/roles/kube_vip/defaults/main.yml` → `kube_vip_version` | v0.8.9 |
| **external-snapshotter** | `platform/external-snapshotter/kustomization.yaml` (`ref=v8.2.0`) | v8.2.0 |
| **cert-manager** | `platform/cert-manager/helmrelease.yaml` → `">=1.16.0 <1.17.0"` (live 1.16.5) | 1.16.5 |
| **CNPG** | `platform/cnpg/helmrelease.yaml` → `">=0.22.0 <1.0.0"` (live chart 0.29.0) | operator ≈1.26/1.27 era |
| **kube-prometheus-stack** | `platform/monitoring/kube-prometheus-stack.yaml` → `">=65.0.0 <66.0.0"` (live 65.8.1) | 65.8.1 |
| **Loki** | `platform/monitoring/loki.yaml` → `">=6.0.0 <7.0.0"` (live 6.55.0) | 6.55.0 |
| **Alloy** | `platform/monitoring/alloy.yaml` → `">=0.10.0 <1.0.0"` (live 0.12.6) | 0.12.6 |
| **Velero** | `platform/velero/helmrelease.yaml` → `">=8.0.0 <9.0.0"` (live 8.7.2, Velero v1.15.x) | 8.7.2 |
| **Kyverno** | `platform/kyverno/admission/helmrelease.yaml` → `version: "3.3.x"` (live 3.3.9) | 3.3.9 |
| **oauth2-proxy** | `platform/oauth2-proxy/helmrelease.yaml` → `">=7.0.0 <8.0.0"` (live 7.18.0) | 7.18.0 |
| **external-dns** | `platform/externaldns/helmrelease.yaml` → `">=1.15.0 <1.16.0"` (live 1.15.2) | 1.15.2 |
| **headlamp** | `platform/headlamp/helmrelease.yaml` → `">=0.41.0 <0.42.0"` (live 0.41.0) | 0.41.0 |
| **Flux** | installed by `flux bootstrap` (toolbox v2.4.0); controllers live in `flux-system` | v2.4.0 |
| **tofu cloud image** | `infra/tofu/variables.tf` → `image_source`, `image_file_name` | `debian-12-generic-amd64.qcow2` (bookworm) |
### 1.3 Live platform component versions (verified 2026-08-29)
Flux 2.4.0 (source-controller v1.4.1, kustomize-controller v1.4.0, helm-controller v1.1.0,
image-automation v0.39.0, image-reflector v0.33.0, notification v1.4.0) · cert-manager v1.16.5 ·
CNPG chart 0.29.0 (PostgreSQL 16.4, 3 single-instance clusters: keycloak-db, nextcloud-db,
semaphore-db) · Cilium 1.16.5 · Kyverno 3.3.9 · Longhorn 1.7.3 · Alloy 0.12.6 ·
kube-prometheus-stack 65.8.1 · Loki 6.55.0 · oauth2-proxy 7.18.0 · Velero 8.7.2 (+plugin-for-aws
v1.11.1) · KubeVirt v1.9.0 · CDI v1.66.0 · snapshot-controller v8.2.0 · kube-vip v0.8.9 ·
Gateway API CRDs v1 (v1.2.1 line).
### 1.4 CRD / API surface the repo actually uses (grep of `platform/ apps/ catalog/ clusters/`)
`v1`, `apps/v1`, `networking.k8s.io/v1`, `rbac.authorization.k8s.io/v1`, `storage.k8s.io/v1`,
`gateway.networking.k8s.io/v1`, `cilium.io/v2`, `monitoring.coreos.com/v1`,
`helm.toolkit.fluxcd.io/v2`, `source.toolkit.fluxcd.io/v1`, `kustomize.toolkit.fluxcd.io/v1`,
`image.toolkit.fluxcd.io/v1beta2` ⚠, `postgresql.cnpg.io/v1`, `kubevirt.io/v1`,
`cdi.kubevirt.io/v1beta1`, `cert-manager.io/v1`, `kyverno.io/v1`, `k8s.keycloak.org/v2alpha1`,
`snapshot.storage.k8s.io/v1`, `client.authentication.k8s.io/v1beta1` (kubeconfig exec plugin).
**None of these are Kubernetes *core* APIs removed in 1.32-1.36.** The only removal that bites is a
**Flux** CRD (`image.toolkit.fluxcd.io/v1beta2`) — see §3.
---
## 2. k3s upgrade path (sequential, one minor at a time)
### 2.1 The path and why skipping is unsafe
```
1.31.5+k3s1 → 1.32.z → 1.33.z → 1.34.z → 1.35.z → 1.36.z (+k3s1 each)
```
**Skipping minors is not supported.** Kubernetes' version-skew policy allows the kube-apiserver and
components to differ by at most **one minor** during an upgrade, and a downgrade of the etcd
datastore is not supported. Jumping >1 minor risks an apiserver that can't read objects written by
an intermediate storage version and control-plane components outside the skew window. On k3s (all
three nodes are servers/apiserver), the whole control plane moves together per hop, so the rule is:
**one minor per campaign, all three nodes brought to the same minor before the next.**
Source: .
### 2.2 Target patch per minor (confirm at execution)
Pick the **latest patch of each minor** from (or the k3s
channel server `https://update.k3s.io/v1-release/channels`, where channel `v1.3X` resolves to the
latest patch of that minor). Known-latest as of writing:
| Hop | Target (min) | Notes / source |
|---|---|---|
| → 1.32 | latest `v1.32.z+k3s1` | confirm on releases page |
| → 1.33 | latest `v1.33.z+k3s1` | k3s 1.33 release notes: |
| → 1.34 | latest `v1.34.z+k3s1` | confirm |
| → 1.35 | latest `v1.35.z+k3s1` | confirm |
| → 1.36 | **`v1.36.3+k3s1`** (Aug 4 2026) or newer (`v1.36.4+k3s1` reported) | |
### 2.3 k3s-specific breaking changes across the path
- **containerd major bump 1.7 → 2.x.** k3s 1.36.3 ships **containerd v2.3.2-k3s2** (current node is
1.7.23-k3s2). The containerd v1→v2 migration happens somewhere in the 1.32-1.34 window. It is
transparent for standard workloads, but: (a) `config.toml` schema changed — this repo sets
`device_ownership_from_security_context` via `--nonroot-devices` (k3s config.yaml), which k3s
regenerates into the new containerd config on restart; verify it survives (KubeVirt/CDI Block-mode
PVCs depend on it — see `roles/k3s_server/tasks/main.yml`). (b) Any node with custom
`/etc/rancher/k3s/config.toml.tmpl` would need review — this cluster has none.
- **etcd 3.5 → 3.6.** k3s 1.36.3 ships **etcd v3.6.14-k3s1**. etcd datastore version bumps are
**one-way** (no downgrade). This is the single strongest reason for the pre-hop etcd snapshot gate
(§6) and defines the point-of-no-return (§7).
- **Traefik chart v40 breaking change** in k3s ≥1.36.2 (ingress-nginx provider rename). **N/A** —
this cluster disables Traefik (`k3s_disable: "traefik,servicelb,local-storage"`); Cilium Gateway
API owns ingress.
- **kube-proxy / flannel / network-policy already disabled** (`--disable-kube-proxy`,
`--flannel-backend=none`, `--disable-network-policy`) so k8s 1.36's **IPVS-mode kube-proxy
deprecation is N/A** — Cilium is the kube-proxy replacement.
- Always read the **Kubernetes urgent-upgrade notes** for each minor before the hop:
(`CHANGELOG-1.3X.md` →
"Urgent Upgrade Notes").
---
## 3. Removed / deprecated API audit (1.32 → 1.36)
### 3.1 Kubernetes core removals/deprecations per minor
| k8s | Removed / deprecated | Affects this repo? |
|---|---|---|
| **1.32** | **`flowcontrol.apiserver.k8s.io/v1beta3`** (FlowSchema, PriorityLevelConfiguration) **removed** — use `flowcontrol.apiserver.k8s.io/v1` (GA since 1.29). Field rename `assuredConcurrencyShares`→`nominalConcurrencyShares`. | **No** — repo defines no FlowSchema/PriorityLevelConfiguration CRs. |
| **1.33** | `status.nodeInfo.kubeProxyVersion` **field removed** from Node. | **No** — nothing reads it. |
| **1.34** | **AppArmor** annotations path deprecations continue; `AppArmor` GA'd earlier. | No repo AppArmor annotations. |
| **1.35** | Minor graduations; no removal that touches this repo's API set. | No. |
| **1.36** | **`gitRepo` volume permanently disabled**; **Service `externalIPs` deprecated**; **kube-proxy IPVS** on a deprecation clock. | **No** — no gitRepo volumes, no `externalIPs`, kube-proxy already replaced by Cilium. |
Sources: ·
·
per-minor `CHANGELOG-1.3X.md`.
**Conclusion: the repo's manifests use no Kubernetes core API removed in 1.32-1.36.** (Grep for
`flowcontrol.apiserver`, `gitRepo:`, `externalIPs:`, `policy/v1beta1`, `batch/v1beta1`,
`autoscaling/v2beta*`, `extensions/v1beta1`, `PodSecurityPolicy` returns **nothing**.)
### 3.2 The one real removal that bites — Flux `image.toolkit.fluxcd.io/v1beta2`
**Flux v2.7+ removed `image.toolkit.fluxcd.io/v1beta2`** (ImageRepository, ImagePolicy,
ImageUpdateAutomation → graduated to `image.toolkit.fluxcd.io/v1`). Flux v2.7+ is required for k8s
1.36 (§4.1). The repo still uses `v1beta2` in:
- `platform/image-automation/image-update-automation.yaml`
- `platform/image-automation/per-service/pihole.example.yaml`
(plus any per-service ImageRepository/ImagePolicy generated from that template.)
**Required migration (do BEFORE upgrading Flux to v2.7+):** follow the official
"Upgrade Procedure for Flux v2.7+" — migrate stored objects off v1beta2, then update every manifest
in Git from `image.toolkit.fluxcd.io/v1beta2` → `image.toolkit.fluxcd.io/v1`.
Source: ·
.
### 3.3 Detection tooling — run BEFORE each hop
Add a read-only pre-hop gate using **both** a live-cluster scan and a manifest scan:
```bash
# pluto — scans live cluster AND Helm releases for deprecated/removed apiVersions, target minor
./infra/devcontainer/dev.sh run bash -lc 'pluto detect-all-in-cluster --target-versions k8s=v1.3X.0 -owide'
# or scan the rendered manifests in git:
./infra/devcontainer/dev.sh run bash -lc 'pluto detect-files -d platform -d apps --target-versions k8s=v1.3X.0'
# kubent (kube-no-trouble) — quick live scan
./infra/devcontainer/dev.sh run bash -lc 'kubent --target-version 1.3X'
```
- `pluto` and `kubent` detect **built-in** deprecated APIs from their bundled version data; they do
**not** know about third-party CRD removals (e.g. the Flux `v1beta2` above) unless given a custom
deprecation list. So pair the tool pass with the manual §3.2 check and a `grep` of the repo for
each component's deprecated CRD versions before its own upgrade.
- Neither tool is currently in the toolbox image; add to `infra/devcontainer/Containerfile` +
`versions.env`, or run once via `go install`/release binary inside the devcontainer.
- Sources: · .
---
## 4. Component compatibility matrix & upgrade sequencing
### 4.1 Kubernetes-version support per component (target = k8s 1.36)
| Component | Current | k8s-1.36 target (confirm patch) | k8s support fact | Must move… | Source |
|---|---|---|---|---|---|
| **Cilium** | 1.16.5 | **1.20.x** | 1.16:≤~1.30 · 1.17:1.29-1.32 · 1.18:1.30-1.33 · 1.19:1.32-1.35 · 1.20:1.33-1.36 | **in lockstep, every hop** (pacing blocker #1) | compatibility.fyi/cilium; docs.cilium.io/…/compatibility |
| **Gateway API CRDs** | v1.2.1 | **v1.6.x** (Cilium 1.20 targets v1.6.1 — verify) | too old for Cilium 1.20 | with each Cilium hop | docs.cilium.io/en/v1.20/…/gateway-api |
| **Longhorn** | 1.7.3 | **1.12.x** (1.12.1) | 1.7:≤~1.31 · 1.11.1:1.32-1.35 · 1.12:adds 1.36; min k8s stays v1.21 | one minor at a time (pacing blocker #2) | endoflife.date/longhorn; longhorn.io best-practices; SUSE support matrix |
| **KubeVirt** | **v1.9.0** | **v1.9.x (already there)** | v1.9 validates 1.34/1.35/1.36 (N-2) | **no upgrade needed** | github.com/kubevirt/sig-release k8s-support-matrix |
| **CDI** | **v1.66.0** | keep paired w/ KubeVirt v1.9 | same window as KubeVirt | no upgrade needed (keep pair) | kubevirt/containerized-data-importer releases |
| **external-snapshotter / snapshot-controller** | v8.2.0 | **latest v8.x (≥8.6)** | v8 tracks current k8s; snapshot CRDs fwd/back-compatible | loose — opportunistic | github.com/kubernetes-csi/external-snapshotter releases |
| **cert-manager** | 1.16.5 | **1.21.x** | 1.21 supports 1.33-1.36 | before/with the later hops | cert-manager.io/docs/releases |
| **CNPG** | chart 0.29.0 (op ≈1.26/1.27) | **1.30.x** | 1.30 supports 1.34/1.35/1.36; 1.29 supports 1.33-1.35 | before the 1.36 hop | cloudnative-pg.io/docs/devel/supported_releases |
| **Flux** | v2.4.0 | **v2.7+ (latest, ~v2.8.x)** | Flux N-2; v2.7+ needed for 1.36 | after §3.2 v1beta2→v1 migration | fluxcd.io/flux/releases |
| **kube-prometheus-stack** | 65.8.1 | **~88.x+** (supports kubectl/k8s 1.36) | chart 88.x tested on 1.36 | before/with later hops; **apply updated CRDs separately** | github.com/prometheus-community/helm-charts |
| **Loki (chart)** | 6.55.0 | **latest 6.x/newer** | uses stable core APIs — loose | opportunistic | grafana/helm-charts |
| **Alloy (chart)** | 0.12.6 | **latest** | loose (DaemonSet, stable APIs) | opportunistic | grafana/alloy |
| **Velero** | chart 8.7.2 (v1.15) | **latest (v1.17+/chart ~10.x)** | v1.16 tested→1.33, v1.17 tested→1.34; "1.18-latest" expected | before the 1.36 hop | velero.io support-matrix; compatibility.fyi/velero |
| **Kyverno** | 3.3.9 | **latest 3.x (N-2 for 1.36)** | Kyverno follows k8s N-2; 3.3.x too old for 1.36 | before/with later hops | kyverno.io/docs/installation/releases |
| **oauth2-proxy** | 7.18.0 | **latest 7.x** | loose | opportunistic | oauth2-proxy chart |
| **external-dns** | 1.15.2 | **latest 1.x** | loose | opportunistic | kubernetes-sigs/external-dns |
| **kube-vip** | v0.8.9 | **latest (k8s.io 0.36.x-based)** | leader-election uses coordination.k8s.io/v1 (stable); latest bumps client-go to 0.36 | before/with the 1.36 hop | github.com/kube-vip/kube-vip releases |
| **headlamp** | 0.41.0 | latest | loose UI | opportunistic | headlamp chart |
### 4.2 The pacing blockers, and the per-hop lockstep
**Cilium and Longhorn are the two items that force the pace** — each supports only ~3-4 k8s minors
and each upgrades **one minor at a time (no skipping, pre-flight required)**. KubeVirt/CDI are
already at target, so the VM stack is *not* a blocker this cycle. The mapping:
| Window | k8s target | Cilium (HelmRelease) | Gateway API CRDs | Longhorn (HelmRelease) | Other components landed in this window |
|---|---|---|---|---|---|
| **W1** | 1.32 | **1.17** (covers 1.29-1.32) | bump toward Cilium 1.17's required version | **1.8** | — |
| **W2** | 1.33 | **1.18** (1.30-1.33) | bump | **1.9** | cert-manager→1.18/1.19; kube-prometheus-stack CRDs refresh |
| **W3** | 1.34 | **1.19** (1.32-1.35) | bump | **1.10** | CNPG→1.28/1.29; Velero→latest |
| **W4** | 1.35 | 1.19 still covers 1.35 (optional →1.20 early) | as needed | **1.11** | Kyverno→latest; **Flux v1beta2→v1 migration + Flux→v2.7+** |
| **W5** | 1.36 | **1.20** (1.33-1.36) | **v1.6.x** | **1.12** | cert-manager→1.21; CNPG→1.30; kube-vip→latest; snapshot-controller→latest v8.x |
Ordering rule **inside** each window (critical): **CNI/storage compatibility first, then the k3s
control-plane hop, then verify.** Concretely per window:
1. Land the **component** upgrades that must *precede* the new k8s minor (Cilium minor, Gateway API
CRDs, Longhorn minor, and any component whose *current* version won't run on the new k8s). Let
Flux reconcile; verify healthy on the **still-current** k8s.
2. Run the **k3s minor hop** (§5) node-by-node.
3. Verify the components again on the **new** k8s; then land the "opportunistic/loose" bumps.
> Rationale for "Cilium before the apiserver hop": if the apiserver moves to a minor the running
> Cilium doesn't support, CNI/agent may fail to start on that node and the node never goes Ready.
> Cilium's own guidance is to run its **pre-flight check** DaemonSet before each Cilium minor bump
> and to upgrade Cilium **before or alongside** the k8s minor within its supported window.
---
## 5. Upgrade method — evaluation & recommendation (D1)
### 5.1 Options
**(a) Rancher system-upgrade-controller (SUC) with Plans.** Canonical k3s upgrade tool. A `Plan`
targets nodes by label, concurrency 1, HA-aware, drains → replaces the k3s binary → uncordons.
- **Pros:** official, declarative, handles server-vs-agent ordering; could live in GitOps.
- **Cons for THIS cluster:** SUC's default **drain evicts pods**, which on this slow-HDD Longhorn
triggers replica **rebuild** storms that starve etcd WAL fsync and flap the VIP — the exact
failure `ssd-etcd-isolation.md` documents. You'd have to disable drain (`drain: {enabled:false}`)
and still manually apply the Longhorn freeze, at which point SUC's main value (drain automation) is
gone. Worse, SUC **automates away the human gate between nodes** — the user's hard requirement is
an explicit backup + PASS/STOP verification *between* every node, which SUC does not pause for.
**(b) Scripted in-place per-node binary upgrade (Ansible-driven).** Re-run the k3s install script
with the new `INSTALL_K3S_VERSION` on one node at a time; k3s replaces its binary and restarts,
etcd re-joins at the new version. This is exactly the `ssd-etcd-execution-runbook.md` bounce shape.
- **Pros:** reuses the **proven** freeze + leader-last + per-node-verify pattern; no drain (no
rebuild storm — replicas *reattach*); the human gate sits naturally between nodes; minimal moving
parts. **Note the `creates:` guard:** the existing install task self-skips on a running node, so
the upgrade path must invoke the install script *explicitly* (a dedicated play/tag, or an ad-hoc
`curl … | INSTALL_K3S_VERSION= sh -s - server`), not rely on bumping the var alone.
- **Cons:** does not touch the OS (handled separately, §6/D2); slightly more manual than SUC.
**(c) Immutable re-provision via tofu.** Bump `image_source` + `k3s_version`, `tofu replace` one VM
at a time, node rejoins etcd fresh and Longhorn rebuilds that node's replicas.
- **Pros:** **also solves the OS upgrade** in the same motion; clean, reproducible cattle nodes.
- **Cons:** destroys the node's etcd member + **all** its Longhorn replicas → full **rebuild** (not
reattach) on the slow HDD per node (throttled but heavy, ~hours for 18 volumes' replicas across
three rebuilds); the etcd SSD `virtio2` is wiped on replace (fine for a 3-node HA etcd — the new
member syncs from the other two). Heavier and slower than (b) for a *pure k8s* hop.
### 5.2 Recommendation
- **k3s minor hops (W1-W5): choose (b), scripted in-place per-node.** It matches the cluster's
proven safe-bounce, avoids rebuild storms, and preserves the mandatory per-node human gate. Pin
the new tag in `roles/k3s_server/defaults/main.yml` (`k3s_version`) **and** drive the actual
upgrade with an explicit install-script invocation (bypassing the `creates:` guard), `serial: 1`,
leader-last.
- **Node OS (Debian 12→13): choose (c), immutable re-provision**, as a **separate** campaign (§8).
A clean trixie image beats an in-place `apt` dist-upgrade for reproducibility, and the nodes are
already cattle (cloud-init + `node_baseline` rebuild them). Accept the per-node Longhorn rebuild
there (that campaign *wants* rebuild, throttled at limit=1, since replicas are genuinely gone).
- SUC remains a reasonable future option **once the cluster is on fast disks** (the prod/mini-PC
Phase-7 cluster) where drain-driven rebuild is cheap; note it for later.
---
## 6. Data-validation gates at EVERY step (hard requirement)
Every **node bounce** (there are 3 per k3s window; 3 per OS window) is wrapped by a pre-step backup
gate and a post-step verification gate. Every **window** additionally takes a full cluster backup
before the first node.
### 6.1 PRE-STEP backup gate (before the first node of a window, and re-confirm before each node)
```bash
# 1) etcd on-demand snapshot (k3s native) — the primary rollback artifact. Run on the leader.
./infra/devcontainer/dev.sh run bash -lc 'cd infra/ansible && ansible -b -m shell \
-a "k3s etcd-snapshot save --name pre-1.3X-$(date +%Y%m%d%H%M)"'
# verify it exists + is copied off-node (snapshots land in /var/lib/rancher/k3s/server/db/snapshots;
# also archived to S3 if configured). Confirm size > 0 and note the name.
# 2) Velero backup (cluster resources + PVC data via kopia FSB → MinIO 192.168.1.9:9000)
./infra/devcontainer/dev.sh run bash -lc 'velero backup create pre-1.3X-$(date +%Y%m%d%H%M) --wait'
./infra/devcontainer/dev.sh run bash -lc 'velero backup describe --details' # Phase=Completed, 0 errors
# 3) CNPG logical backup + verify, for each of keycloak-db / nextcloud-db / semaphore-db
# (they already run continuous WAL + daily base backups to MinIO via barmanObjectStore).
# Trigger an on-demand base backup and confirm it completes:
./infra/devcontainer/dev.sh run kubectl -n create -f - <<'EOF'
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata: { name: pre-upg-, namespace: }
spec: { method: barmanObjectStore, cluster: { name: } }
EOF
./infra/devcontainer/dev.sh run kubectl -n get backup pre-upg- -o jsonpath='{.status.phase}' # completed
# capture an integrity baseline to compare after:
./infra/devcontainer/dev.sh run kubectl -n exec -1 -- psql -tAc \
"select (select count(*) from pg_database), (select sum(('x'||substr(md5(t.oid::text),1,8))::bit(32)::bigint) from pg_class t)"
```
**PASS to proceed:** etcd snapshot present (>0 bytes, name recorded) **AND** Velero backup
`Completed` with 0 errors **AND** all three CNPG on-demand backups `completed` with baseline
captured.
**STOP (do not start the hop):** any backup fails, errors, or cannot be verified restorable.
### 6.2 The per-node bounce itself (reuse `ssd-etcd-execution-runbook.md`)
Per node `NODE=k3s-dev-N`, `VMID`, **leader last** (re-verify leader before each node):
```bash
# leader check
./infra/devcontainer/dev.sh run bash -lc 'cd infra/ansible && ansible k3s_servers -m shell \
-a "curl -s http://127.0.0.1:2381/metrics | grep etcd_server_is_leader"'
# 1) FREEZE Longhorn rebuilds (reattach, not rebuild) — the proven safe-bounce
./infra/devcontainer/dev.sh run kubectl -n longhorn-system patch settings.longhorn.io \
concurrent-replica-rebuild-per-node-limit --type=merge -p '{"value":"0"}'
# 2) Upgrade k3s ON THIS NODE ONLY (explicit install, bypassing the creates: guard):
./infra/devcontainer/dev.sh run bash -lc 'cd infra/ansible && ansible $NODE -b -m shell \
-a "curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.3X.z+k3s1 sh -s - server"'
# (k3s replaces its binary and restarts; embedded etcd re-joins at the new version)
# 3) VERIFY this node fully before touching the next (see §6.3), THEN
# 4) THAW rebuilds only after 18/18 healthy:
./infra/devcontainer/dev.sh run kubectl -n longhorn-system patch settings.longhorn.io \
concurrent-replica-rebuild-per-node-limit --type=merge -p '{"value":"1"}'
```
### 6.3 POST-STEP verification gate (after each node; full set after the last node of a window)
```bash
./infra/devcontainer/dev.sh run kubectl get nodes -o wide # NODE Ready, VERSION = new minor
./infra/devcontainer/dev.sh run kubectl get --raw=/readyz/etcd # ok
./infra/devcontainer/dev.sh run bash -lc 'cd infra/ansible && ansible k3s_servers -m shell \
-a "curl -s http://127.0.0.1:2381/metrics | grep etcd_server_has_leader"' # all = 1
./infra/devcontainer/dev.sh run kubectl -n longhorn-system get volumes.longhorn.io \
-o custom-columns=R:.status.robustness --no-headers | grep -c healthy # 18
./infra/devcontainer/dev.sh run kubectl -n kube-system rollout status ds/cilium --timeout=300s
./infra/devcontainer/dev.sh run bash -lc 'flux get kustomizations -A && flux get helmreleases -A' # all Ready
# etcd snapshot restorability spot-check (list; a scratch restore is the W-level drill, §7)
./infra/devcontainer/dev.sh run bash -lc 'cd infra/ansible && ansible -b -m shell \
-a "k3s etcd-snapshot ls"'
# CNPG integrity vs the §6.1 baseline
./infra/devcontainer/dev.sh run kubectl -n exec -1 -- pg_isready # accepting connections
# re-run the row-count/checksum query and diff against baseline (must match)
```
**App smoke tests (the QA-sweep URL set)** — after each *window* completes, expect HTTP 200/302
(or the app's healthy code) from:
`auth` · `crafty` · `gitea` · `grafana` · `headlamp` · `jackett` · `keycloak`
(`/realms/homelab/.well-known/openid-configuration`) · `nextcloud` · `pihole` · `plex` ·
`qbittorrent` · `radarr` · `seerr` · `semaphore` · `sonarr` · `unifi` — all under
`*.home.keithjackson.dev`. Plus one KubeVirt VM check (a VMI Running / console reachable).
**PASS to advance to the next node/window:** all nodes Ready at the expected version · etcd
`/readyz/etcd`=ok and 3/3 have a leader · **18/18** Longhorn healthy (reattached, no rebuild storm)
· Cilium DaemonSet rolled out · all Flux Kustomizations + HelmReleases Ready · CNPG `pg_isready` +
row-count/checksum unchanged vs baseline · QA-sweep all green.
**STOP (halt, leave freeze=0, do not advance):** etcd `/readyz` not ok · a member without a leader ·
VIP `192.168.2.50:6443` flapping · a node not rejoining/not Ready · any Longhorn volume Faulting ·
k3s crash-looping · a CNPG DB failing `pg_isready` or checksum drift · a Flux HR stuck not-Ready ·
any QA-sweep URL down. Recovery per §7.
---
## 7. Rollback per step & point-of-no-return
### 7.1 Per-node (before etcd majority is on the new minor)
The safest rollback for a single bad node is to **re-pin the old k3s version on that node** and
re-run the install script for the old tag, then verify it rejoins at the old minor; or reset the
member and rejoin from the other two (2/3 quorum holds), per `ssd-etcd-isolation.md` §4.5. Because
you go **leader-last and one node at a time**, at any moment ≤1 node is on the new minor until the
2nd node upgrades.
### 7.2 Point-of-no-return per hop
**When the *second* server reaches the new minor, etcd majority (2/3) is on the new datastore
version** (etcd 3.5→3.6 across the path is one-way). Past that point, "roll back the node" is no
longer enough — a true rollback is an **etcd snapshot restore** of the whole cluster to the pre-hop
snapshot:
```bash
# cluster-wide restore from the pre-hop snapshot (DISRUPTIVE — full control-plane stop):
# on ONE server, with k3s stopped on all three:
./infra/devcontainer/dev.sh run bash -lc 'cd infra/ansible && ansible k3s_servers -b -m shell -a "systemctl stop k3s"'
./infra/devcontainer/dev.sh run bash -lc 'cd infra/ansible && ansible -b -m shell \
-a "k3s server --cluster-reset --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/"'
# then wipe db on the other two and rejoin them (standard k3s cluster-reset-restore flow),
# and PIN k3s_version back to the old tag before they re-join.
```
After an etcd restore, **Longhorn volume data is recovered by Velero (kopia FSB) / CNPG PITR** for
anything written between snapshot and restore — this is why the §6.1 triad (etcd + Velero + CNPG)
is mandatory, not just etcd.
### 7.3 Window-level DR drill (do once, in W1, to prove the path)
Before the first real hop, prove restorability end-to-end: `velero restore create --from-backup
--namespace-mappings :-scratch` into a scratch namespace and confirm data lands
(this is the existing Phase-4 R-STATE-03 gate). If the drill fails, **do not begin the upgrade.**
### 7.4 OS-campaign rollback
Re-provision is inherently reversible per node: if a re-provisioned trixie node misbehaves,
`tofu` re-apply with `image_source` reverted to bookworm re-creates it on Debian 12; the node
rejoins etcd and rebuilds replicas as with any replacement. Point-of-no-return is the same etcd
majority rule if OS re-provision is (against advice) combined with a k3s bump — which is why D3 says
keep them separate.
---
## 8. Node OS upgrade — Debian 12 → 13 (trixie)
- **Debian 13 "trixie" released 2025-08-09, ships Linux 6.12 LTS** (up from bookworm's 6.1).
Support to 2028-08 (LTS 2030-08). Sources: ·
.
- **Kernel implications (positive):** 6.12 is newer than Cilium's/Longhorn's minimums and improves
eBPF/BPF feature coverage for Cilium and has current `iscsi_tcp`/`nfs`/`dm_crypt`/`overlay`/
`br_netfilter` modules Longhorn needs (all already pinned in
`roles/node_baseline/defaults/main.yml` → `node_baseline_kernel_modules`). No known regression for
this stack; `open-iscsi`/`nfs-common` packages exist on trixie.
- **Method (D2): immutable re-provision.** Bump `infra/tofu/variables.tf`:
- `image_source` → `https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2`
- `image_file_name` → `debian-13-generic-amd64.qcow2`
Then `tofu apply -replace=proxmox_virtual_environment_vm.node["k3s-dev-N"]` **one node at a time**,
let `node_baseline` + `k3s_server` (secondary/join) rebuild it, and let it rejoin etcd. Because a
replaced node loses its Longhorn replicas, run this campaign with rebuild **throttled at 1** (not
frozen at 0 — you *want* the rebuild here), wait for 18/18 healthy before the next node, and keep
the same per-node backup + verify gates (§6).
- **Coordinate with k3s (D3):** run the OS campaign **after** the cluster is stable on a chosen k3s
minor (either finish the whole 1.36 ladder first, or interleave one full campaign between k3s
windows) — **never** re-provision to trixie *and* bump k3s in the same node bounce; that entangles
two failure domains and defeats per-step rollback.
- **In-place `apt` dist-upgrade** is the fallback if re-provision is undesirable, but it is discarded
here: it mutates cattle nodes outside the tofu/cloud-init source of truth, risks half-migrated
states across three nodes, and violates the repo's "changes go through IaC" principle.
---
## 9. Sequencing & HA safety
- **Quorum:** 3 servers, embedded etcd, quorum 2/3. Exactly **one** node down at a time keeps
quorum. Never bounce two simultaneously.
- **Leader last:** re-verify the etcd leader before each node (`etcd_server_is_leader` on :2381) and
upgrade followers first, leader last, so leadership only moves once and late.
- **VIP stability:** kube-vip floats 192.168.1.49 to the current leader; with one node down the VIP
fails over to a surviving leader and the API stays reachable through the pfSense NAT
(192.168.2.50:6443). Watch for VIP flapping as a STOP signal.
- **Rebuild-freeze (reattach, not rebuild):** for k3s in-place hops set
`concurrent-replica-rebuild-per-node-limit=0` around each bounce so a briefly-down node's replicas
**reattach** instead of triggering a rebuild storm that starves etcd WAL fsync (the whole reason
etcd is on a dedicated SSD). Thaw to `1` only after 18/18 healthy. (For the OS re-provision
campaign use `1`, not `0`, since replicas are genuinely gone and must rebuild.)
- **CNPG note:** all three DBs are **single-instance** (`instances: 1`) — each incurs a brief
outage when its pod reschedules during a node bounce. Order/announce accordingly; the app
smoke-tests (§6.3) will blip while a DB pod moves. (Consider `instances: 3` for the prod cluster.)
- **KubeVirt/CDI:** already 1.36-ready; VMs without `evictionStrategy: LiveMigrate` simply
Evict+restart on a node bounce (we do **not** drain in method (b), so VMs are only affected on
the node actually bouncing).
**Estimated maintenance windows:** per k3s minor ≈ **60-120 min** (3 nodes × ~15-30 min bounce +
verify + freeze/thaw + component reconcile), so **5 windows ≈ 5-10 h** total spread across days,
plus the W-level backups. OS re-provision campaign ≈ **2-4 h per node** (dominated by Longhorn
rebuild of that node's share of 18 volumes on HDD) → ~1-1.5 days, one node per session.
---
## 10. Dev-first, then prod (Phase-7 carry-over)
This dev run is the rehearsal for the eventual **prod / mini-PC** cluster. What carries over:
- The **method** (scripted in-place per-node for k3s; immutable re-provision for OS), the
**leader-last + freeze + per-node-gate** discipline, and the **backup triad** gate are
cluster-agnostic and become the prod runbook verbatim.
- The **component lockstep table** (§4.2) is the reusable dependency map; re-confirm exact patch
numbers at prod time (they will have moved).
- **Differences to expect on prod:** fast disks (NVMe/SSD) make Longhorn rebuild cheap → **SUC
becomes viable** there (drain no longer dangerous), and multi-instance CNPG (`instances: 3`)
removes the single-DB outage blip. Prod may also run dedicated workers, changing SUC Plan
server-vs-agent ordering.
- The **Flux v1beta2→v1 migration** (§3.2) is a one-time repo change shared by both clusters — do it
once, both benefit.
---
## Appendix A — pre-flight checklist per window (copy/paste)
```
[ ] Read k3s release notes for the target minor + k8s CHANGELOG "Urgent Upgrade Notes"
[ ] pluto/kubent scan (live + manifests) against target minor → clean (or Flux v1beta2 handled)
[ ] Component pre-reqs landed & Ready on CURRENT k8s (Cilium minor, Gateway CRDs, Longhorn minor, …)
[ ] Cilium pre-flight check DaemonSet passed for the Cilium minor bump
[ ] PRE-STEP backup gate PASS: etcd snapshot + Velero Completed + 3× CNPG completed + baselines captured
[ ] (W1 only) Velero restore DR drill into scratch ns PASS
[ ] Leader identified; node order = followers → leader
[ ] For each node: freeze(0) → upgrade → §6.3 verify PASS → thaw(1) → 18/18 healthy → next
[ ] After last node: full §6.3 + QA-sweep all green + KubeVirt VM check
[ ] Land opportunistic/loose component bumps; Flux all Ready
[ ] Pin the new k3s_version in roles/k3s_server/defaults/main.yml; PR → main (never commit to main)
[ ] Record outcome in DECISIONS-LOG.md
```
## Appendix B — sources
- k3s releases & notes: · · · channels
- k8s version-skew: · deprecation guide · v1.36 sneak peek
- Cilium compat: · · Gateway API
- Longhorn: · · ·
- KubeVirt/CDI: ·
- external-snapshotter:
- cert-manager:
- CNPG:
- Flux: · v2.7 upgrade · image API
- kube-prometheus-stack:
- Velero: ·
- Kyverno:
- kube-vip: ·
- Debian 13: ·
- Tools: pluto · kubent
```