Files
home-network-k3s-impl/docs/plans/cluster-upgrade-plan.md
T
jakman85andClaude Opus 4.8 cbc2301b30 docs: reconcile docs/comments with post-upgrade cluster state
Text-only cleanup after the upgrade campaign. Updates stale version
strings, prose, and comments to the verified current facts; no
Kubernetes behavior or version pins changed except the kubectl
toolchain bump (below).

Verified facts applied: k3s v1.36.4+k3s1, Debian 13 trixie,
Cilium 1.20.1 / Gateway API v1.6.1, Longhorn 1.12.1, node RAM 24 GiB,
dedicated virtio2 SSD etcd disk per node, Velero chart 12.x / v1.18,
Grafana 13.2.0.

- Version strings across infra/ and platform/ READMEs + comments
- kubectl toolchain 1.31.4 -> 1.36.4 (devcontainer/ci/conformance
  versions.env; 1.31 vs a 1.36 cluster exceeded the +-1 skew)
- Retired-single-apps-Kustomization refs -> apps-wave-0/1/2 + apps-vms
  with per-wave SOPS decryption (flux/clusters docs + bootstrap.sh)
- COMPLETED/historical banners on the three executed plan docs
- Resolved TODO(WS-SEC) markers -> Renovate-maintained ranges
- Appended dated DECISIONS-LOG entry (2026-08-30) superseding the
  2026-08-26 LIVE STATE line

Left as revalidation items (not blind-edited): external-snapshotter
v8.2.0 vs 1.36, Cilium ext_authz/forward-auth relevance under 1.20.1,
conformance ImageVolume feature-gate assertions under 1.36.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-30 15:13:12 -05:00

40 KiB
Raw Blame History

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: https://kubernetes.io/releases/version-skew-policy/.

2.2 Target patch per minor (confirm at execution)

Pick the latest patch of each minor from https://github.com/k3s-io/k3s/releases (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: https://docs.k3s.io/release-notes/v1.33.X
→ 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) https://docs.k3s.io/release-notes/v1.36.X

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: https://github.com/kubernetes/kubernetes/blob/master/CHANGELOG/ (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: https://kubernetes.io/docs/reference/using-api/deprecation-guide/ · https://www.kubernetes.io/blog/2026/03/30/kubernetes-v1-36-sneak-peek/ · 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: https://github.com/fluxcd/flux2/discussions/5572 · https://fluxcd.io/flux/components/image/imagerepositories/.

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:

# 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: https://github.com/FairwindsOps/pluto · https://github.com/doitintl/kube-no-trouble.

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=<tag> 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)

# 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 <leader> -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 <name> --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 <ns> create -f - <<'EOF'
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata: { name: pre-upg-<ns>, namespace: <ns> }
spec: { method: barmanObjectStore, cluster: { name: <cluster> } }
EOF
./infra/devcontainer/dev.sh run kubectl -n <ns> get backup pre-upg-<ns> -o jsonpath='{.status.phase}'  # completed
# capture an integrity baseline to compare after:
./infra/devcontainer/dev.sh run kubectl -n <ns> exec <cluster>-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):

# 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)

./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 <leader> -b -m shell \
  -a "k3s etcd-snapshot ls"'
# CNPG integrity vs the §6.1 baseline
./infra/devcontainer/dev.sh run kubectl -n <ns> exec <cluster>-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:

# 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 <node1> -b -m shell \
  -a "k3s server --cluster-reset --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/<pre-1.3X-snap>"'
# 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 <pre-snap> --namespace-mappings <ns>:<ns>-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: https://www.debian.org/News/2025/20250809 · https://www.debian.org/releases/trixie/.
  • 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