Skip to content

Commit ff2bd58

Browse files
wrkodeclaude
andauthored
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

.dockerignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Keep the Docker build context small and fast. The provider-builder stage does
2+
# `COPY . .` to run `go build`, so without this every build ships the whole repo
3+
# (including build/, which holds multi-GB VM ISOs and disks) to the daemon.
4+
# Only the Go module + the config dirs the Dockerfile explicitly COPYs
5+
# (containerd/, systemd/, sysctl/, modules-load/) are needed.
6+
7+
.git
8+
.github
9+
.claude
10+
build
11+
bin
12+
docs
13+
samples
14+
*.md
15+
coverage.out
16+
*.iso
17+
*.qcow2
18+
*.tar
19+
*.log

internal/etcdsnapshot/snapshot.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Package etcdsnapshot takes a best-effort etcd snapshot before a destructive
2+
// kubeadm upgrade apply (ADR-12 U5).
3+
//
4+
// SECURITY: an etcd snapshot is a full plaintext dump of every cluster Secret and
5+
// the cluster PKI. It is therefore written ONLY to the encrypted persistent
6+
// partition, 0600 root:root, OFF the reset/artifact paths (so a later reset never
7+
// wipes or follows it), and single-retained. If the persistent partition's
8+
// encryption cannot be confirmed the snapshot is REFUSED -- the provider never
9+
// writes a plaintext full-cluster dump (this is the one place ADR-12 escalates
10+
// from warn to refuse). The provider never copies the snapshot off the node;
11+
// durability/custody is the operator's responsibility.
12+
//
13+
// It is best-effort and MUST NOT block the upgrade: every failure mode (including
14+
// the encryption refusal and non-stacked etcd) returns an error that the caller
15+
// logs and then proceeds (#4099-1). Bounded by ctx.
16+
package etcdsnapshot
17+
18+
import (
19+
"context"
20+
"fmt"
21+
"os"
22+
"os/exec"
23+
"path/filepath"
24+
"strings"
25+
"time"
26+
)
27+
28+
// DefaultDir is the snapshot directory. It lives under /var/lib (persisted by
29+
// Kairos onto COS_PERSISTENT) and OFF the reset artifact paths (/etc/kubernetes,
30+
// /var/lib/kubelet, /var/lib/etcd) so reset never wipes or traverses it.
31+
const DefaultDir = "/var/lib/provider-kubernetes/etcd-backup"
32+
33+
// Options configures a snapshot.
34+
type Options struct {
35+
// RootPath is the cluster root (cluster_root_path); locates the etcd manifest,
36+
// member dir, and PKI.
37+
RootPath string
38+
// Dir is the snapshot directory; empty -> DefaultDir. Must be absolute and off
39+
// the reset artifact paths.
40+
Dir string
41+
// EncryptionConfirmed reports whether Dir's backing storage is encrypted. nil
42+
// or false -> the snapshot is refused (no plaintext is written).
43+
EncryptionConfirmed func(ctx context.Context) bool
44+
// Save performs the actual `etcdctl snapshot save <dest>`; nil -> DefaultSave.
45+
Save func(ctx context.Context, rootPath, dest string) error
46+
// Now is the timestamp used in the filename; zero -> time.Now().
47+
Now time.Time
48+
}
49+
50+
// Run performs a best-effort, bounded etcd snapshot. See the package doc for the
51+
// security posture. It returns nil only when a snapshot was actually written.
52+
func Run(ctx context.Context, o Options) error {
53+
if !IsStackedEtcd(o.RootPath) {
54+
return fmt.Errorf("etcd is external/non-stacked on this node; the operator must snapshot etcd before upgrading")
55+
}
56+
if o.EncryptionConfirmed == nil || !o.EncryptionConfirmed(ctx) {
57+
return fmt.Errorf("persistent-partition encryption is unconfirmed; refusing to write a plaintext etcd snapshot (take one manually onto encrypted storage)")
58+
}
59+
60+
dir := o.Dir
61+
if dir == "" {
62+
dir = DefaultDir
63+
}
64+
if err := validateDir(o.RootPath, dir); err != nil {
65+
return err
66+
}
67+
if err := os.MkdirAll(dir, 0o700); err != nil {
68+
return fmt.Errorf("create snapshot dir: %w", err)
69+
}
70+
71+
now := o.Now
72+
if now.IsZero() {
73+
now = time.Now()
74+
}
75+
dest := filepath.Join(dir, fmt.Sprintf("etcd-snapshot-%s.db", now.UTC().Format("20060102T150405Z")))
76+
77+
save := o.Save
78+
if save == nil {
79+
save = DefaultSave
80+
}
81+
if err := save(ctx, o.RootPath, dest); err != nil {
82+
return fmt.Errorf("etcd snapshot save: %w", err)
83+
}
84+
if err := os.Chmod(dest, 0o600); err != nil {
85+
return fmt.Errorf("chmod snapshot 0600: %w", err)
86+
}
87+
// Single-retain: remove any older snapshots so a full-cluster dump never
88+
// accumulates on disk.
89+
pruneOld(dir, dest)
90+
return nil
91+
}
92+
93+
// IsStackedEtcd reports whether this node runs a stacked etcd, by the presence of
94+
// the etcd static-pod manifest or the etcd member dir under root. Pure file check.
95+
func IsStackedEtcd(root string) bool {
96+
for _, p := range []string{
97+
filepath.Join(root, "etc", "kubernetes", "manifests", "etcd.yaml"),
98+
filepath.Join(root, "var", "lib", "etcd", "member"),
99+
} {
100+
if _, err := os.Stat(p); err == nil {
101+
return true
102+
}
103+
}
104+
return false
105+
}
106+
107+
// validateDir rejects a snapshot dir that is not absolute or that lives under a
108+
// reset artifact path (where a later reset's os.RemoveAll would wipe it, or where
109+
// it would sit inside the etcd data dir it is backing up).
110+
func validateDir(root, dir string) error {
111+
if !filepath.IsAbs(dir) {
112+
return fmt.Errorf("snapshot dir must be absolute, got %q", dir)
113+
}
114+
clean := filepath.Clean(dir)
115+
forbidden := []string{
116+
filepath.Clean(filepath.Join(root, "etc", "kubernetes")),
117+
filepath.Clean(filepath.Join(root, "var", "lib", "kubelet")),
118+
filepath.Clean(filepath.Join(root, "var", "lib", "etcd")),
119+
}
120+
for _, f := range forbidden {
121+
if clean == f || strings.HasPrefix(clean, f+string(os.PathSeparator)) {
122+
return fmt.Errorf("snapshot dir %q must not be under the reset artifact path %q", dir, f)
123+
}
124+
}
125+
return nil
126+
}
127+
128+
// pruneOld removes etcd-snapshot-*.db files in dir other than keep (single-retain).
129+
func pruneOld(dir, keep string) {
130+
entries, err := os.ReadDir(dir)
131+
if err != nil {
132+
return
133+
}
134+
for _, e := range entries {
135+
if e.IsDir() {
136+
continue
137+
}
138+
name := e.Name()
139+
if !strings.HasPrefix(name, "etcd-snapshot-") || !strings.HasSuffix(name, ".db") {
140+
continue
141+
}
142+
p := filepath.Join(dir, name)
143+
if p == keep {
144+
continue
145+
}
146+
_ = os.Remove(p)
147+
}
148+
}
149+
150+
// DefaultSave runs `etcdctl snapshot save <dest>` against the local stacked etcd
151+
// member using the kubeadm-managed etcd client certificates. Bounded by ctx.
152+
func DefaultSave(ctx context.Context, rootPath, dest string) error {
153+
pki := filepath.Join(rootPath, "etc", "kubernetes", "pki", "etcd")
154+
cmd := exec.CommandContext(ctx, "etcdctl",
155+
"--endpoints=https://127.0.0.1:2379",
156+
"--cacert="+filepath.Join(pki, "ca.crt"),
157+
"--cert="+filepath.Join(pki, "healthcheck-client.crt"),
158+
"--key="+filepath.Join(pki, "healthcheck-client.key"),
159+
"snapshot", "save", dest,
160+
)
161+
cmd.Env = append(os.Environ(), "ETCDCTL_API=3")
162+
if out, err := cmd.CombinedOutput(); err != nil {
163+
return fmt.Errorf("etcdctl: %w: %s", err, strings.TrimSpace(string(out)))
164+
}
165+
return nil
166+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package etcdsnapshot
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
)
10+
11+
// stackedRoot makes a temp RootPath that looks like a stacked-etcd control plane.
12+
func stackedRoot(t *testing.T) string {
13+
t.Helper()
14+
root := t.TempDir()
15+
manifest := filepath.Join(root, "etc", "kubernetes", "manifests", "etcd.yaml")
16+
if err := os.MkdirAll(filepath.Dir(manifest), 0o755); err != nil {
17+
t.Fatal(err)
18+
}
19+
if err := os.WriteFile(manifest, []byte("kind: Pod\n"), 0o600); err != nil {
20+
t.Fatal(err)
21+
}
22+
return root
23+
}
24+
25+
func TestRun_RefusesWhenEncryptionUnconfirmed(t *testing.T) {
26+
root := stackedRoot(t)
27+
called := false
28+
err := Run(context.Background(), Options{
29+
RootPath: root,
30+
Dir: filepath.Join(t.TempDir(), "snap"),
31+
EncryptionConfirmed: func(context.Context) bool { return false },
32+
Save: func(context.Context, string, string) error { called = true; return nil },
33+
Now: time.Unix(0, 0),
34+
})
35+
if err == nil {
36+
t.Fatal("expected refusal when encryption is unconfirmed")
37+
}
38+
if called {
39+
t.Fatal("Save must NOT run when encryption is unconfirmed (no plaintext snapshot)")
40+
}
41+
}
42+
43+
func TestRun_NonStackedEtcdRefused(t *testing.T) {
44+
// No etcd manifest/member dir -> not stacked -> operator must snapshot.
45+
err := Run(context.Background(), Options{
46+
RootPath: t.TempDir(),
47+
EncryptionConfirmed: func(context.Context) bool { return true },
48+
Save: func(context.Context, string, string) error { return nil },
49+
})
50+
if err == nil {
51+
t.Fatal("expected refusal on non-stacked/external etcd")
52+
}
53+
}
54+
55+
func TestRun_WritesSnapshotWhenEncryptedAndStacked(t *testing.T) {
56+
root := stackedRoot(t)
57+
dir := filepath.Join(t.TempDir(), "etcd-backup")
58+
// Seed a stale snapshot to verify single-retain pruning.
59+
if err := os.MkdirAll(dir, 0o700); err != nil {
60+
t.Fatal(err)
61+
}
62+
stale := filepath.Join(dir, "etcd-snapshot-old.db")
63+
if err := os.WriteFile(stale, []byte("old"), 0o600); err != nil {
64+
t.Fatal(err)
65+
}
66+
67+
var savedDest string
68+
err := Run(context.Background(), Options{
69+
RootPath: root,
70+
Dir: dir,
71+
EncryptionConfirmed: func(context.Context) bool { return true },
72+
Save: func(_ context.Context, _ string, dest string) error {
73+
savedDest = dest
74+
return os.WriteFile(dest, []byte("snap"), 0o600)
75+
},
76+
Now: time.Unix(1700000000, 0),
77+
})
78+
if err != nil {
79+
t.Fatalf("unexpected error: %v", err)
80+
}
81+
if _, err := os.Stat(savedDest); err != nil {
82+
t.Fatalf("snapshot not written: %v", err)
83+
}
84+
info, _ := os.Stat(savedDest)
85+
if info.Mode().Perm() != 0o600 {
86+
t.Fatalf("snapshot mode = %o, want 0600", info.Mode().Perm())
87+
}
88+
if _, err := os.Stat(stale); !os.IsNotExist(err) {
89+
t.Fatalf("stale snapshot must be pruned (single-retain): %v", err)
90+
}
91+
}
92+
93+
func TestValidateDir_RejectsArtifactPaths(t *testing.T) {
94+
root := "/"
95+
for _, bad := range []string{
96+
"/etc/kubernetes/backup",
97+
"/var/lib/etcd/snap",
98+
"/var/lib/kubelet/x",
99+
"relative/dir",
100+
} {
101+
if err := validateDir(root, bad); err == nil {
102+
t.Fatalf("validateDir must reject %q", bad)
103+
}
104+
}
105+
if err := validateDir(root, DefaultDir); err != nil {
106+
t.Fatalf("DefaultDir must be allowed, got %v", err)
107+
}
108+
}

0 commit comments

Comments
 (0)