Commit ff2bd58
feat: cluster upgrade lifecycle (kubeadm upgrade, ADR-12 + R1) (#9)
* feat(upgrade): U1 - version signals + UpgradePath (ADR-12 foundation)
Add the pure foundation for the cluster upgrade lifecycle (ADR-12):
- kubeadm.UpgradePath(clusterVersion, targetVersion) decides whether a minor
upgrade is due and returns a terminal error for unsafe transitions
(downgrade, skip-level >1 minor, cross-major, out-of-window) so callers fail
loud rather than attempting them (skew enforcement). Same-minor yields not-due
(reboot-safe no-op). kubeadm permits only +1 minor per upgrade.
- actualstate.State gains ClusterVersion (the cluster's current version, to be
read from the kubeadm-config ConfigMap) as an observed signal.
Pure and table-driven tested (1.34->1.35 due, 1.35->1.36 due, same-minor no-op,
skip-level/downgrade/out-of-window/invalid refused). No behavior change yet; the
Plan branches, prober wiring, and executor land in later slices.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: William Rizzo <william.rizzo@gmail.com>
* feat(upgrade): U2 - Plan upgrade decision table (ADR-12)
Implement the pure per-node upgrade decision (ADR-12, refined with staff-architect
to fix the follower-detection gap). Plan now takes an operator-pinned target and
evaluates upgrade first for members:
- Per-node convergence signals (authoritative observed state, no sentinels):
control plane = kube-apiserver static-pod manifest image tag
(State.NodeComponentVersion); worker = the RUNNING kubelet version
(State.RunningKubeletVersion). These distinguish 'cluster flipped' from 'this
node converged' -- the cluster-config version alone cannot, because with
one-at-a-time delivery it flips before followers boot.
- Decision table: a CP that sees the cluster not yet flipped self-elects to
ActionUpgradeApply; followers (CPs/workers) ActionWaitForClusterUpgrade then
ActionUpgradeNode; already-converged nodes fall through to ActionNone
(reboot-safe); unsafe transitions (skip-level/downgrade/out-of-window) return
the terminal ActionRefuseUpgrade before any destructive action.
- Plan signature gains the target arg (run.go passes until U6 wires the pin),
so behavior is unchanged until the executor + wiring land.
Four new Actions added; reconcile now uses kubeadm.UpgradePath. Table-driven
tests cover every row. The executor (U4) and prober wiring (U3) follow.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: William Rizzo <william.rizzo@gmail.com>
* feat(upgrade): U3 - prober version signals (ADR-12)
FileProber now surfaces the ADR-12 upgrade signals, hardware-free:
- NodeComponentVersion: parsed from this CP's kube-apiserver static-pod manifest
image tag under RootPath (per-node control-plane convergence signal); empty on
workers / pre-init.
- ClusterVersion + RunningKubeletVersion: injected funcs (like KubeletHealthy /
ControlPlaneReachable), nil-safe, so probing needs no real cluster.
Tests use a temp RootPath with a fake apiserver manifest and injected version
funcs. Production wiring (kubeadm-config read; kubelet version) lands in U6.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: William Rizzo <william.rizzo@gmail.com>
* feat(upgrade): U4 - executor upgrade actions (ADR-12, security-gated)
Add the kubeadm upgrade executor methods (all argv via the Runner, bounded, no
secret on argv, stdout not logged):
- runUpgradeApply: pre-apply lost-race re-check (if the cluster already reached
the target, converge via "upgrade node" instead); then
"kubeadm upgrade apply <target> --yes --certificate-renewal=true". NEVER passes
--upload-certs (root-equivalent cert-key stays mint-only, ADR-11 #3). Calls an
optional best-effort etcd snapshot hook first (U5 fills it in). Restarts kubelet.
- runUpgradeNode: "kubeadm upgrade node", then restart kubelet.
- waitForClusterUpgrade: bounded poll until the cluster version reaches the
target minor (the apply ran), else loud timeout; never hangs (#4099-1).
- ActionRefuseUpgrade: terminal (ErrTerminal-wrapped UpgradePath message).
- restartKubelet: injectable; default runs "systemctl daemon-reload" then
"systemctl restart kubelet" via argv (no shell).
Behavior tests assert the exact apply argv, that --upload-certs/--certificate-key
never appear, the race re-check degrades to upgrade node, refuse-upgrade is
terminal, and the wait is bounded. Run wiring (target/cluster probes, budget) is
U6; the etcd snapshot is U5. Security review (U-B1..U-B7) before the PR lands.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: William Rizzo <william.rizzo@gmail.com>
* feat(upgrade): U5 - best-effort etcd snapshot before apply (ADR-12, security-gated)
New internal/etcdsnapshot package: takes a bounded, best-effort etcd snapshot
before a destructive "kubeadm upgrade apply", with the security invariants the
review requires:
- REFUSES to write a plaintext snapshot when persistent-partition encryption is
unconfirmed (an etcd snapshot is a full plaintext dump of every cluster secret
+ the PKI). This is the one place ADR-12 escalates from warn to refuse.
- Stacked-etcd only; external/non-stacked -> refuse + (caller) warn the operator.
- Writes 0600 root:root under /var/lib/provider-kubernetes/etcd-backup, OFF the
reset artifact paths (validateDir rejects /etc/kubernetes, /var/lib/kubelet,
/var/lib/etcd and relative dirs) so reset never wipes/follows it; single-retained.
- Never offboarded by the provider (operator owns durability/custody).
- Best-effort + bounded: every failure (incl. the encryption refusal) returns an
error the caller logs before proceeding; never blocks the upgrade (#4099-1).
EncryptionConfirmed and the etcdctl Save are injectable for hardware-free tests
(refuse-when-unconfirmed, non-stacked-refused, write+0600+single-retain,
artifact-path rejection). DefaultSave runs etcdctl with the kubeadm etcd client
certs. Wired into the executor's SnapshotEtcd hook (CP role) in U6.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: William Rizzo <william.rizzo@gmail.com>
* feat(upgrade): U6 - wire upgrade into Run (ADR-12)
Make the upgrade lifecycle functional end to end:
- Explicit-pin trigger: an upgrade target is set only when the operator pins
clusterConfiguration.kubernetesVersion (== bundled binary by ADR-3 Resolve);
a newer binary without a pin does NOT auto-upgrade. The version probes are
wired only when a target is pinned, so the no-upgrade path stays hermetic.
- Prober gets ClusterVersion (kubeadm-config ConfigMap via kubectl) and
RunningKubeletVersion (Node object via kubectl) production probes; NodeComponentVersion
comes from the apiserver manifest (already file-based). All injectable via Options.
- Executor gets TargetVersion/ClusterVersion/ClusterVersionProbe/KubeletRestart and,
on a control plane only, the best-effort SnapshotEtcd hook (etcdsnapshot.Run with
the default lsblk-based encryption check; refuses plaintext when unconfirmed).
- Upgrade actions run under the larger UpgradeBudget (10m/attempt, 2 attempts,
20m total); everything else keeps DefaultBudget. Still hard-bounded (#4099-1).
New provider/upgrade.go holds the kubectl/lsblk exec probes (best-effort, return
""/false on error). Added an end-to-end hardware-free test: a pinned controlplane
whose apiserver manifest is old and cluster version not yet flipped drives
"kubeadm upgrade apply v1.35.0" and restarts kubelet (both injected).
The upgrade lifecycle (U1-U6) is now functionally complete. Security review
(U-B1..U-B7) and the docs + VM smoke (U7) follow before the PR lands.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: William Rizzo <william.rizzo@gmail.com>
* docs(upgrade): security-review nits N-a/N-b (ADR-12 comments only)
Address the two non-blocking doc nits from the security-architect U-B1..U-B7
review (verdict: all PASS, cleared to land):
- N-b: document that encryptionConfirmedDefault assumes a DIRECT mount for the
snapshot dir (overlay/bind mounts can mislead findmnt --target); a false
negative is safe (snapshot skipped), only a genuine dm-crypt node yields true.
- N-a: note that a bounded retry of upgrade apply re-takes the (single-retained,
best-effort) snapshot and the pre-apply re-check makes the retry safe.
Comments only; no behavior change. U-N CI backlog (live-binary upgrade-edge
assertions) remains tracked under ADR-12.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: William Rizzo <william.rizzo@gmail.com>
* feat(upgrade): ADR-12-R1 - kubelet-config repair + API-free detection (U8-U10)
The VM smoke proved the original ADR-12 model deadlocks in the Kairos image
model: A/B swaps the kubelet binary first, the new kubelet crashloops on a flag
the old kubeadm wrote but the new kubelet removed (e.g. --pod-infra-container-image
in 1.35), the control plane never comes up, and detecting the upgrade via the API
(kubeadm-config CM) fails because the API is down -> upgrade never runs.
Fix (ADR-12-R1, staff-architect + security re-review pending):
- API-FREE detection: planUpgrade keys the CP decision on the kube-apiserver
static-pod manifest tag (State.NodeComponentVersion, file-based) vs the bundled
target, NOT the API ClusterVersion. Drop the ClusterVersion=="" gate that made
the provider bail exactly when it needed to act. Skew is checked against the
manifest tag.
- New ActionRepairKubeletConfig: when an upgrade is due and the local apiserver is
unreachable (State.APIServerReachable=false), run `kubeadm init phase
kubelet-start --config <0600 tmpfs>` first. This regenerates
/var/lib/kubelet/{kubeadm-flags.env,config.yaml} with the NEW kubeadm from LOCAL
config (no API, no PKI, no secret -- removed flags simply are not written), so
the kubelet starts, the old control plane returns, and the API becomes reachable.
Then UpgradeApply runs (its pre-apply re-check self-degrades to upgrade node for
a follower). Workers repair too when their kubelet is unhealthy.
- New signals: State.APIServerReachable + FileProber.APIServerReachable (local
/healthz probe, injectable); provider.localAPIHealthyProbe wired in Run; the
executor's post-repair wait (LocalAPIReachable) bounded, never hangs.
Pure plan table updated for the re-key (API-up->apply, API-down->repair+apply,
skew->refuse, converged->no-op). Executor repair test asserts the exact
kubelet-start argv, no secret in the rendered config, shred, and a bounded wait.
All gates green. VM re-smoke + security re-review next (U11).
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: William Rizzo <william.rizzo@gmail.com>
* fix(upgrade): include RepairKubeletConfig in upgrade-budget selection (N-1)
Security re-review nit N-1: containsUpgradeAction now counts
ActionRepairKubeletConfig, so any plan containing the repair gets the larger
UpgradeBudget. Harmless today (repair is always paired with apply/node, which
already trip the check), but makes budget selection robust if a future planner
ever emits repair alone -- otherwise its bounded local-API wait could time out
under the 2-minute DefaultBudget. Comment-/one-line behavior hardening.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: William Rizzo <william.rizzo@gmail.com>
* build: add .dockerignore to keep the build context tiny
The provider-builder stage runs `COPY . .` for `go build`, so without a
.dockerignore every `make image` (and the CI image matrix) shipped the entire
repo to the daemon -- including build/ (multi-GB VM ISOs and qcow2 disks),
filling the host disk and slowing builds (observed: a 17GB+ build context).
Exclude build/, bin/, docs/, samples/, .git, .github, .claude, *.md and VM
artifacts; the Go module and the config dirs the Dockerfile COPYs
(containerd/, systemd/, sysctl/, modules-load/) are retained.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: William Rizzo <william.rizzo@gmail.com>
---------
Signed-off-by: William Rizzo <william.rizzo@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>1 parent c6909ce commit ff2bd58
16 files changed
Lines changed: 1379 additions & 18 deletions
File tree
- internal
- etcdsnapshot
- kubeadm
- action
- provider
- reconcile
- actualstate
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
0 commit comments