Files
felhom.eu/hub/internal/osupdates/kernel_test.go
T
admin ab3b7ea2f4
gates / gates (push) Successful in 2m47s
hub v0.143.0 (code): the kernel lane — the day-before household mail, the night instruction, the operator's kernel set (R-836, decision 172)
KernelDue / KernelNotify (09-20 h Budapest, one per 20 h, max 3, registered
address, only an accepted mail counts) / os_update.kernel {kver, tonight}
(no mail, no step) / layer kernel ingest + operator events / Approve kernel
set after every ring-0 box booted it healthily after a night stage / two
System page cells. 11 §5.11 written; §5.10 status corrected (proven).
Installer uninstall knows the two GRUB generators (unreleased).
Evidence: audits/kernel-lane-2026-10-07/ (red-proofs, boot timing).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-07 15:34:14 +02:00

285 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package osupdates
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-836, `09` §3 decision 172: the kernel lane's hub half — due boxes, the day-before mail (no mail, no step), the
// block, the events, the operator's approval.
const kOld, kNew = "7.0.2-6-pve", "7.0.14-22-pve"
// laneFacts stores a host report whose system facts carry the kernel lane.
func (f *fix) laneFacts(t *testing.T, host, cust, running, phase, to string, problems ...string) {
t.Helper()
lane := map[string]any{"running": running, "default": running, "phase": phase, "to": to, "from": kOld}
if len(problems) > 0 {
lane["setup_problems"] = problems
}
body, _ := json.Marshal(map[string]any{"system": map[string]any{"facts": map[string]any{"host": map[string]any{
"kernel_running": running, "kernel_lane": lane}}}})
if err := f.s.Store.SaveHostReport(host, cust, body, store.HostReportDenorm{AgentVersion: "0.152.0"}); err != nil {
t.Fatal(err)
}
}
// hostWithPendingKernel reports a host step whose pending list carries the kernel meta-package upgrade.
func (f *fix) hostWithPendingKernel(t *testing.T, host string) {
f.ingest(t, host, Report{Layer: LayerHost, Trigger: "night", Mode: "apply", Outcome: "nothing", Healthy: true,
Pending: []PendingPkg{{Name: "proxmox-kernel-7.0", From: "7.0.2-6", To: "7.0.14-22", Origin: []string{"Proxmox Debian Repository"}},
{Name: "pve-manager", From: "9.2.2", To: "9.2.21", Origin: []string{"Proxmox Debian Repository"}}}})
}
func kreport(outcome, trigger string, healthy bool) Report {
return Report{Layer: LayerKernel, Trigger: trigger, Mode: "apply", Outcome: outcome, Healthy: healthy, ReleaseID: kNew,
Kernel: json.RawMessage(`{"from":"` + kOld + `","to":"` + kNew + `"}`)}
}
func (f *fix) mail(sent *[]string, fail error) {
f.s.KernelMail = func(cust, kver string) (string, error) {
if fail != nil {
return "", fail
}
*sent = append(*sent, cust+":"+kver)
return "hu", nil
}
}
func budapest(t *testing.T, h int) time.Time {
loc, err := time.LoadLocation("Europe/Budapest")
if err != nil {
t.Skip("no tzdata")
}
return time.Date(2026, 10, 8, h, 30, 0, 0, loc)
}
// A ring-0 box with a pending kernel is due; its block names the kernel, NOT tonight, until the household was mailed.
// COMPANION RED-PROOF (observed): set Tonight without a notice in KernelBlockFor → "tonight before any mail".
func TestKernel_DueButNotToldIsNotTonight(t *testing.T) {
f := newFix(t)
f.hostWithPendingKernel(t, "hp")
f.laneFacts(t, "hp", "c-hp", kOld, "none", "")
k, why := f.s.KernelDue("hp")
if k != kNew {
t.Fatalf("due = %q (%s)", k, why)
}
b := f.s.DesiredBlock("hp").Kernel
if b == nil || b.Kver != kNew || b.Tonight {
t.Fatalf("tonight before any mail: %+v", b)
}
}
// The mail goes out only 09:00–20:00 Budapest; then the block says tonight (for 24 h); never two within 20 h.
func TestKernel_DayBeforeMailMakesTonight(t *testing.T) {
f := newFix(t)
f.hostWithPendingKernel(t, "hp")
f.laneFacts(t, "hp", "c-hp", kOld, "none", "")
var sent []string
f.mail(&sent, nil)
f.now = budapest(t, 3) // night: no mail
if told := f.s.KernelNotify(); len(told) != 0 || len(sent) != 0 {
t.Fatalf("mailed at 03:30: %v", sent)
}
f.now = budapest(t, 21)
if f.s.KernelNotify(); len(sent) != 0 {
t.Fatalf("mailed at 21:30: %v", sent)
}
f.now = budapest(t, 10)
if told := f.s.KernelNotify(); len(told) != 1 || len(sent) != 1 || sent[0] != "c-hp:"+kNew {
t.Fatalf("told=%v sent=%v", told, sent)
}
if b := f.s.DesiredBlock("hp").Kernel; b == nil || !b.Tonight || b.NotifiedAt == "" {
t.Fatalf("after the mail the block must say tonight: %+v", b)
}
if !contains(f.bumps, "hp") || !contains(f.events, EventKernelNotice) {
t.Fatalf("the box must be bumped and the operator told: bumps=%v events=%v", f.bumps, f.events)
}
f.now = f.now.Add(5 * time.Hour)
if f.s.KernelNotify(); len(sent) != 1 {
t.Fatalf("a second mail within 20 h: %v", sent)
}
f.now = budapest(t, 10).Add(25 * time.Hour)
if b := f.s.DesiredBlock("hp").Kernel; b == nil || b.Tonight {
t.Fatalf("a mail older than 24 h no longer covers tonight: %+v", b)
}
}
// No mail → no step: a mail the service did not accept is not recorded; the block stays not-tonight; the operator hears.
// COMPANION RED-PROOF (observed): record the notice before checking the send error → "a failed mail made tonight".
func TestKernel_NoMailNoStep(t *testing.T) {
f := newFix(t)
f.hostWithPendingKernel(t, "hp")
f.laneFacts(t, "hp", "c-hp", kOld, "none", "")
var sent []string
f.mail(&sent, errors.New("the household has no e-mail address"))
f.now = budapest(t, 11)
f.s.KernelNotify()
if b := f.s.DesiredBlock("hp").Kernel; b == nil || b.Tonight {
t.Fatalf("a failed mail made tonight: %+v", b)
}
if !contains(f.events, EventKernelNotice) {
t.Fatalf("the operator must hear the household could not be told: %v", f.events)
}
f.s.KernelMail = nil
f.s.KernelNotify()
if b := f.s.DesiredBlock("hp").Kernel; b == nil || b.Tonight {
t.Fatalf("no mailer → never tonight: %+v", b)
}
}
// An ended step is never retried by itself; the operator decides.
func TestKernel_EndedStepIsNeverRetried(t *testing.T) {
for _, out := range []string{"applied", "fell_back", "health_failed", "self_reverted", "revert_failed"} {
f := newFix(t)
f.hostWithPendingKernel(t, "hp")
f.laneFacts(t, "hp", "c-hp", kOld, "fell_back", kNew)
f.ingest(t, "hp", kreport(out, "boot", out == "applied"))
if k, why := f.s.KernelDue("hp"); k != "" || !strings.Contains(why, "operator decides") {
t.Fatalf("%s: still due (%q %q)", out, k, why)
}
if f.s.DesiredBlock("hp").Kernel != nil {
t.Fatalf("%s: block still names a kernel", out)
}
}
// a refusal for good ends it; a passing one (a lock) does not
f := newFix(t)
f.hostWithPendingKernel(t, "hp")
f.laneFacts(t, "hp", "c-hp", kOld, "none", "")
r := kreport("refused", "night", false)
r.Refused = json.RawMessage(`{"code":"R9","reason":"another apt holds the lock"}`)
f.ingest(t, "hp", r)
if k, _ := f.s.KernelDue("hp"); k != kNew {
t.Fatal("a transient refusal (R9) must not end the step")
}
r.Refused = json.RawMessage(`{"code":"R20","reason":"/boot/efi is not vfat"}`)
f.ingest(t, "hp", r)
if k, _ := f.s.KernelDue("hp"); k != "" {
t.Fatal("R20 must end the step")
}
}
// Ring 1 is due ONLY for a kernel a signed job staged; a box with a setup problem or switched off never is.
func TestKernel_Ring1OnlyWhenStagedAndSetupMustBeFine(t *testing.T) {
f := newFix(t)
f.hostWithPendingKernel(t, "cust1")
f.laneFacts(t, "cust1", "c-1", kOld, "none", "")
if k, _ := f.s.KernelDue("cust1"); k != "" {
t.Fatal("ring 1 due without a staged kernel")
}
f.laneFacts(t, "cust1", "c-1", kOld, "staged", kNew)
if k, why := f.s.KernelDue("cust1"); k != kNew {
t.Fatalf("ring 1 with a staged kernel not due: %s", why)
}
f.laneFacts(t, "cust1", "c-1", kOld, "staged", kNew, "/boot/efi is not a mounted vfat ESP")
if k, _ := f.s.KernelDue("cust1"); k != "" {
t.Fatal("a box that cannot do a one-shot is never due")
}
_ = f.s.Store.SetOSEnabled("hp", false)
f.hostWithPendingKernel(t, "hp")
f.laneFacts(t, "hp", "c-hp", kOld, "none", "")
if k, _ := f.s.KernelDue("hp"); k != "" {
t.Fatal("switched off but due")
}
}
// The household is told at most KernelNoticeMax times for one kernel; then the operator hears once.
func TestKernel_AtMostThreeMails(t *testing.T) {
f := newFix(t)
f.hostWithPendingKernel(t, "hp")
f.laneFacts(t, "hp", "c-hp", kOld, "none", "")
var sent []string
f.mail(&sent, nil)
day := budapest(t, 10)
for i := 0; i < 5; i++ {
f.now = day.Add(time.Duration(i) * 24 * time.Hour)
f.s.KernelNotify()
}
if len(sent) != KernelNoticeMax {
t.Fatalf("mails = %d, want %d", len(sent), KernelNoticeMax)
}
n := 0
for _, e := range f.events {
if e == EventKernelNotice {
n++
}
}
if n != KernelNoticeMax+1 {
t.Fatalf("notice events = %d (3 told + 1 'no further mail')", n)
}
}
// Each outcome is an operator event; "applied" also gives the household its timeline line; ingest never treats a
// kernel report as a guest report.
func TestKernel_IngestEvents(t *testing.T) {
f := newFix(t)
f.ingest(t, "hp", kreport("staged", "night", true))
f.ingest(t, "hp", kreport("applied", "boot", true))
f.ingest(t, "hp", kreport("fell_back", "boot", false))
if c := countOf(f.events, EventKernelStep); c != 3 {
t.Fatalf("kernel events = %d: %v", c, f.events)
}
if !contains(f.events, EventApplied) {
t.Fatal("an applied kernel gives the household its line")
}
if rep, _ := f.s.Store.LatestOSReport("hp", LayerGuest); rep != nil {
t.Fatalf("a kernel report was stored as a guest report: %+v", rep)
}
if rep, _ := f.s.Store.LatestOSReport("hp", LayerKernel); rep == nil || rep.Outcome != "fell_back" {
t.Fatalf("kernel report not stored on its layer: %+v", rep)
}
}
// The operator's approval: every ring-0 box booted the same kernel healthily after a night stage — never before.
// COMPANION RED-PROOF (observed): drop the `night` check in KernelStatus → "approved without a night stage".
func TestKernel_ApproveNeedsEveryRing0BoxHealthyAfterANightStep(t *testing.T) {
f := newFix(t)
if _, err := f.s.ApproveKernel(); err == nil {
t.Fatal("approved with nothing booted")
}
f.ingest(t, "hp", kreport("staged", "night", true))
f.ingest(t, "hp", kreport("applied", "boot", true))
f.ingest(t, "n100", kreport("staged", "signed", true)) // a by-day stage is not a night step
f.ingest(t, "n100", kreport("applied", "boot", true))
if _, err := f.s.ApproveKernel(); err == nil || !strings.Contains(err.Error(), "night") {
t.Fatalf("approved without a night stage: %v", err)
}
f.ingest(t, "n100", kreport("staged", "night", true))
f.ingest(t, "n100", kreport("applied", "boot", true))
id, err := f.s.ApproveKernel()
if err != nil || !strings.HasPrefix(id, "os-kernel-") {
t.Fatalf("%q %v", id, err)
}
rel, _ := f.s.Store.LatestOSRelease(LayerKernel)
if !strings.Contains(rel.PackagesJSON, `"proxmox-kernel-7.0"`) || !strings.Contains(rel.PackagesJSON, "proxmox-kernel-7.0.14-22-pve-signed") {
t.Fatalf("release = %s", rel.PackagesJSON)
}
if contains(f.bumps, "cust1") {
t.Fatal("an approved kernel set must nudge no ring-1 box (a signed job only)")
}
// a fell-back ring-0 box blocks the button
g := newFix(t)
g.ingest(t, "hp", kreport("staged", "night", true))
g.ingest(t, "hp", kreport("fell_back", "boot", false))
if st, _ := g.s.KernelStatus(); !strings.Contains(st.Waiting, "fell_back") {
t.Fatalf("waiting = %q", st.Waiting)
}
}
func contains(xs []string, x string) bool { return countOf(xs, x) > 0 }
func countOf(xs []string, x string) int {
n := 0
for _, v := range xs {
if v == x {
n++
}
}
return n
}