diff --git a/CHANGELOG.md b/CHANGELOG.md index 547d73d..49366cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## Unreleased — part of v0.153.0: ring 0 stages exactly the told kernel (R-898; `09` §3 decision 176) (2026-10-07) + +**Delivery: the agent binary only** — no root file changed (the wrapper is unchanged; its tests gained two cases). + +- `internal/osupdate/kernel.go`: ring 0's night kernel step stages EXACTLY the kernel the household was told about + (select `listed`, `KernelSet(kver)` = the series meta-package and the signed image at the kernel's own version) instead + of "whatever is pending tonight". Seen 2026-10-07: demo-felhom was told about 7.0.14-20 while its sources offered + 7.0.14-22 by night — the old code staged `pending-kernel` and the wrapper refused it (R23), losing the night. A told + version that is no longer installable is refused by the wrapper before any change (R7) and the hub tells the household + again for the newer kernel (hub v0.143.1). Tests `TestKernel_Ring0ToldNightStagesThenReboots` (red-proved against the + old select), `TestKernelSet`; wrapper `test_ring0_listed_installs_the_told_kernel_not_the_newest`, + `test_ring0_told_kernel_gone_is_refused_before_any_change`. + ## v0.152.0 — the kernel lane (R-836; `09` §3 decisions 164, 172; `11` §5.11) (2026-10-07) Released by `scripts/release-agent.sh`: binary sha256 `95ff42208e36ba49b6e2b09a97e81a6fa11562ecc8042f1ed18d378b8b1f88b1`, diff --git a/configs/test_felhom_os_apply.py b/configs/test_felhom_os_apply.py index 9e2b9be..81ff386 100644 --- a/configs/test_felhom_os_apply.py +++ b/configs/test_felhom_os_apply.py @@ -1838,6 +1838,33 @@ class KernelLane(unittest.TestCase): m.origins = {"proxmox-kernel-7.0": DEB} self.refused(m, "R2") + # R-898: ring 0 stages EXACTLY the told kernel (select listed, the set derived from it) — even when the sources + # offer a newer one by night; a told version that can no longer be installed is refused BEFORE any change (R7). + def test_ring0_listed_installs_the_told_kernel_not_the_newest(self): + f = kfake() + f.live["proxmox-kernel-7.0"] = {"7.0.14-20", "7.0.14-22", "7.0.2-6"} + f.live["proxmox-kernel-7.0.14-20-pve-signed"] = {"7.0.14-20"} + told = [{"name": "proxmox-kernel-7.0", "version": "7.0.14-20", "origin": "Proxmox Debian Repository"}, + {"name": "proxmox-kernel-7.0.14-20-pve-signed", "version": "7.0.14-20", "origin": "Proxmox Debian Repository"}] + f.plan.update(select="listed", packages=told, expect_kver="7.0.14-20-pve") + rc, rep = run(f) + self.assertEqual(rc, 0, rep) + self.assertEqual(rep["authority"], "ring0") + self.assertEqual(f.env, {"felhom_next": "7.0.14-20-pve"}) + self.assertEqual(f.installed["proxmox-kernel-7.0"], "7.0.14-20") + self.assertNotIn("7.0.14-22-pve", f.boot) + + def test_ring0_told_kernel_gone_is_refused_before_any_change(self): + f = kfake() + f.live["proxmox-kernel-7.0"] = {"7.0.14-22"} # 7.0.14-20 is no longer in the archive + told = [{"name": "proxmox-kernel-7.0", "version": "7.0.14-20", "origin": "Proxmox Debian Repository"}, + {"name": "proxmox-kernel-7.0.14-20-pve-signed", "version": "7.0.14-20", "origin": "Proxmox Debian Repository"}] + f.plan.update(select="listed", packages=told, expect_kver="7.0.14-20-pve") + rep = self.refused(f, "R7") + self.assertIsNone(f.env) + self.assertNotIn(osapply.KERNEL_DEFAULT_CFG, f.tree, "refused before the default was even pinned") + self.assertEqual(f.installed["proxmox-kernel-7.0"], "7.0.2-6") + def test_nothing_pending_changes_nothing(self): f = kfake(kernel_pending=[]) rc, rep = run(f) diff --git a/internal/osupdate/kernel.go b/internal/osupdate/kernel.go index 5447476..56cd1f1 100644 --- a/internal/osupdate/kernel.go +++ b/internal/osupdate/kernel.go @@ -119,8 +119,12 @@ func (l *Leg) runKernel(ctx context.Context, runID string, vmid int, trigger str lg.Info("osupdate: kernel step skipped — ring 1 boots only a kernel a signed os_kernel_step staged", "phase", st.Phase, "staged", st.To, "want", want) return Report{} default: + // R-898: EXACTLY the kernel the household was told about — never "whatever is pending tonight" (the sources can + // offer a newer one by night; the step then refused, R23, and the night was lost). A version no longer + // installable is refused by the wrapper before any change (R7) and the hub tells the household again. wr, cerr := l.call(ctx, runID, kernelPlan("apply", vmid, map[string]any{"release_id": "ring0-" + runID, - "select": "pending-kernel", "expect_kver": want, "run_id": runID, "trigger": trigger, "ring": blk.Ring})) + "select": "listed", "packages": KernelSet(want), "expect_kver": want, "run_id": runID, "trigger": trigger, + "ring": blk.Ring})) rep.unsent = reportFile(l.planDir(), runID, LayerKernel, "apply") rep.Kernel = rawOrNil(wr.Kernel) switch { @@ -322,6 +326,21 @@ func truncate(s string, n int) string { return s[:n] } +// KernelSet is the package set that installs exactly kver (R-898; the hub's kernelSet, field-exact): the series +// meta-package and the signed image, both at the kernel's own version. Proxmox keeps old kernel versions in its archive. +// nil for a string that is not a kernel version. +func KernelSet(kver string) []Package { + m := kverSeriesRE.FindStringSubmatch(kver) + if m == nil { + return nil + } + v := kver[:len(kver)-len("-pve")] + return []Package{{Name: "proxmox-kernel-" + m[1], Version: v, Origin: PVEOrigin}, + {Name: "proxmox-kernel-" + kver + "-signed", Version: v, Origin: PVEOrigin}} +} + +var kverSeriesRE = regexp.MustCompile(`^([0-9]+\.[0-9]+)\.[0-9]+-[0-9]+-pve$`) + // KernelStepParams are a signed os_kernel_step's params: the exact kernel set (the wrapper compares it with the plan). type KernelStepParams struct { ReleaseID string `json:"release_id"` diff --git a/internal/osupdate/kernel_test.go b/internal/osupdate/kernel_test.go index 6e25f1a..f9ca9f8 100644 --- a/internal/osupdate/kernel_test.go +++ b/internal/osupdate/kernel_test.go @@ -54,8 +54,12 @@ func TestKernel_Ring0ToldNightStagesThenReboots(t *testing.T) { ap = x } } - if ap["select"] != "pending-kernel" || ap["expect_kver"] != kNew || ap["lane"] != "slow" { - t.Fatalf("stage plan = %v", ap) + // R-898: EXACTLY the told kernel — the listed set derived from it, never "pending" (red before the fix: select was + // pending-kernel, so a newer kernel in the sources by night was refused R23 and the night was lost) + pk, _ := json.Marshal(ap["packages"]) + if ap["select"] != "listed" || ap["expect_kver"] != kNew || ap["lane"] != "slow" || + string(pk) != `[{"name":"proxmox-kernel-7.0","origin":"Proxmox Debian Repository","version":"7.0.14-22"},{"name":"proxmox-kernel-7.0.14-22-pve-signed","origin":"Proxmox Debian Repository","version":"7.0.14-22"}]` { + t.Fatalf("stage plan = %v (packages %s)", ap, pk) } if p.Kernel.Outcome != "staged" || !p.Kernel.Healthy { t.Fatalf("kernel report = %+v", p.Kernel) @@ -312,3 +316,13 @@ func TestKernel_KeptStageReportIsStaged(t *testing.T) { t.Fatalf("kept = %+v", rep) } } + +func TestKernelSet(t *testing.T) { + if got := KernelSet("7.0.14-20-pve"); len(got) != 2 || got[0].Name != "proxmox-kernel-7.0" || got[0].Version != "7.0.14-20" || + got[1].Name != "proxmox-kernel-7.0.14-20-pve-signed" { + t.Fatalf("%+v", got) + } + if KernelSet("7.0; reboot") != nil || KernelSet("") != nil { + t.Fatal("a non-kernel string must give no set") + } +}