hub v0.145.0: the kernel approval rule (operator ruling 2026-10-10, 09 §3 decision 195) + release entry for the security fix, R-922, MAIL-HOLD
gates / gates (push) Successful in 6m3s
gates / gates (push) Successful in 6m3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -915,6 +915,18 @@ its length, and both fixes cost something the household would notice — operato
|
||||
127. **The agent's three by-design abilities (`03` §3.1) stay for now**; revisited before the first paying customer.
|
||||
*Operator ruling 2026-10-05.* (R-861)
|
||||
|
||||
### 2026-10-10 — operator ruling: the kernel approval rule (recorded before the work; the release brief)
|
||||
|
||||
195. **„Approve kernel set" offers the NEWEST kernel version that EVERY ring-0 box has booted healthily at least once
|
||||
after a night stage** (a box may have moved on to a newer kernel since). It no longer requires every ring-0 box's
|
||||
newest step to be the SAME kernel at the same moment. Per box and per kernel only the newest ended step counts: a
|
||||
kernel whose latest step fell back, self-reverted or failed is out of that box's set (an older healthy kernel stays
|
||||
in). Everything else unchanged: the operator clicks; ring 1 takes it only through a signed `os_kernel_step`.
|
||||
*Operator ruling 2026-10-10 (the reviewer's pick).* Why: kernels came out on three days in one week (demo-felhom
|
||||
booted -22 then -23, demo-hp -22), so the old rule showed „the ring-0 boxes booted different kernels" and could
|
||||
chase a moving kernel for ever. Hub v0.145.0, `internal/osupdates/kernel.go` `KernelStatus`, pinned by
|
||||
`kernel_approval_test.go` (red-proved against the old rule). `11` §5.11.
|
||||
|
||||
### 2026-10-08 (14:16) — the decision sheet D1–D10, every one as picked (recorded before the work)
|
||||
|
||||
All ten designs: `audits/day-2026-10-08/`. *Operator ruling 2026-10-08 14:16 („all as picked").* Built the same day on
|
||||
|
||||
@@ -562,8 +562,11 @@ block says `tonight` only within 24 h of a mail the mail service ACCEPTED — **
|
||||
`os_kernel_step` and `os_kernel_notice`; the household's timeline gets the usual "security fixes installed" line after
|
||||
`applied`.
|
||||
|
||||
**Approval** (hub): "Approve kernel set" on the System page appears when every ring-0 box's newest ended kernel step is
|
||||
`applied` for the same kernel, after a NIGHT stage. The approved set is the series meta-package and the signed image. An
|
||||
**Approval** (hub): "Approve kernel set" on the System page offers the **newest kernel version that every ring-0 box has
|
||||
booted healthily at least once after a NIGHT stage** — a box may have moved on since (operator ruling 2026-10-10, `09` §3
|
||||
decision 195; hub v0.145.0). Per box and per kernel only the newest ended step counts: a kernel whose latest step fell
|
||||
back, self-reverted or failed is out of that box's set. *(Before 2026-10-10: every ring-0 box's newest ended step had to
|
||||
be `applied` for the SAME kernel.)* The approved set is the series meta-package and the signed image. An
|
||||
approval nudges no box: ring 1 takes it only through a signed `os_kernel_step` (stage), then its own told night.
|
||||
|
||||
**The System page** shows per box: the running kernel, the next boot, the default (amber while a one-shot flag names
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
### RED: the pre-ruling KernelStatus, same tests
|
||||
=== RUN TestKernelApproval_OffersNewestEveryBoxBooted
|
||||
kernel_approval_test.go:52: n100 booted -22,-23 and hp booted -22: offered "WAITING: the ring-0 boxes booted different kernels (7.0.14-22-pve, 7.0.14-23-pve)", want 7.0.14-22-pve
|
||||
--- FAIL: TestKernelApproval_OffersNewestEveryBoxBooted (0.04s)
|
||||
=== RUN TestKernelApproval_BothBootedNewerOffersNewer
|
||||
--- PASS: TestKernelApproval_BothBootedNewerOffersNewer (0.04s)
|
||||
=== RUN TestKernelApproval_ABoxThatNeverBootedOneBlocks
|
||||
--- PASS: TestKernelApproval_ABoxThatNeverBootedOneBlocks (0.04s)
|
||||
=== RUN TestKernelApproval_AFailedLatestStepDropsThatKernelOnly
|
||||
kernel_approval_test.go:89: -23 fell back on hp (its newest step): offered "WAITING: hp: the kernel step to 7.0.14-23-pve ended fell_back", want 7.0.14-22-pve
|
||||
--- FAIL: TestKernelApproval_AFailedLatestStepDropsThatKernelOnly (0.04s)
|
||||
=== RUN TestKernelApproval_ByDayBootDoesNotCount
|
||||
--- PASS: TestKernelApproval_ByDayBootDoesNotCount (0.03s)
|
||||
### GREEN: the ruling's KernelStatus
|
||||
ok gitea.dooplex.hu/admin/felhom-hub/internal/osupdates 2.484s
|
||||
+9
-2
@@ -1,6 +1,13 @@
|
||||
## Unreleased — a household's cleared mail address is deleted (R-922) and a restored hub starts quiet (MAIL-HOLD) (2026-10-09)
|
||||
## v0.145.0 — a SECURITY fix (one box's key acted for any household), R-922 (a household's clear deletes its address), MAIL-HOLD (a restored hub starts quiet), and the kernel approval rule (2026-10-10)
|
||||
|
||||
Not released: ships with the next hub release. No version bump here.
|
||||
Released 2026-10-10 (operator present for the deploy).
|
||||
|
||||
- **Kernel approval rule** (operator ruling 2026-10-10, `09` §3 decision 195, `11` §5.11): „Approve kernel set" offers
|
||||
the NEWEST kernel every ring-0 box booted healthily at least once after a night stage — no longer the same kernel on
|
||||
every box at the same moment (kernels on three days in one week kept demo-felhom on -23 and demo-hp on -22, and the
|
||||
old rule waited for ever). Per box and kernel only the newest ended step counts (a kernel whose latest step fell back
|
||||
is out; an older healthy one stays). `kernel_approval_test.go` (6 tests; red against the old rule: „booted different
|
||||
kernels" where -22 was due).
|
||||
|
||||
- **SECURITY — one box's key can no longer act for another household** (found 2026-10-09 by a security review of the
|
||||
R-922 change; fixed without a row). `POST /api/v1/preferences` and `POST /api/v1/notify` checked only that the key
|
||||
|
||||
@@ -12,8 +12,9 @@ package osupdates
|
||||
// back in. Only a mail the mail service ACCEPTED is recorded.
|
||||
// - The box's os_update block carries `kernel: {kver, tonight}`; tonight is true only within 24 h of such a mail. No
|
||||
// mail → no step: the agent's night leg refuses a kernel without `tonight`.
|
||||
// - The operator approves a kernel set ("Approve kernel set") once every ring-0 box booted it healthily as the
|
||||
// default after a night step; a ring-1 box takes it only through a signed os_kernel_step.
|
||||
// - The operator approves a kernel set ("Approve kernel set"): the NEWEST kernel every ring-0 box booted healthily at
|
||||
// least once after a night step (operator ruling 2026-10-10; before: the same kernel as every box's current
|
||||
// default); a ring-1 box takes it only through a signed os_kernel_step.
|
||||
//
|
||||
// Pinned by kernel_test.go.
|
||||
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -281,6 +283,95 @@ func (s *Service) kernelIngest(hostID, customerID string, r Report, details map[
|
||||
}
|
||||
}
|
||||
|
||||
// healthyNightKernels reads one ring-0 box's kernel reports (newest first) and returns every kernel whose NEWEST ended
|
||||
// step is a healthy "applied" one-shot boot that followed a night stage of the same kernel. why explains an empty set:
|
||||
// the newest failed step, or a healthy boot without a night stage.
|
||||
func healthyNightKernels(reps []store.OSReport) (map[string]bool, string) {
|
||||
out := map[string]bool{}
|
||||
decided := map[string]bool{}
|
||||
why := ""
|
||||
for _, r := range reps {
|
||||
var kr Report
|
||||
if json.Unmarshal([]byte(r.ReportJSON), &kr) != nil || !kernelEnded[r.Outcome] {
|
||||
continue
|
||||
}
|
||||
k := kernelOf(kr)
|
||||
if k == "" || decided[k] {
|
||||
continue
|
||||
}
|
||||
decided[k] = true
|
||||
if r.Outcome != "applied" || !r.Healthy {
|
||||
if why == "" {
|
||||
why = fmt.Sprintf("— the kernel step to %s ended %s", k, r.Outcome)
|
||||
}
|
||||
continue
|
||||
}
|
||||
night := false
|
||||
for _, o := range reps {
|
||||
var ok Report
|
||||
if json.Unmarshal([]byte(o.ReportJSON), &ok) == nil && o.Outcome == "staged" && ok.Trigger == "night" &&
|
||||
kernelOf(ok) == k && !o.ReceivedAt.After(r.ReceivedAt) {
|
||||
night = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !night {
|
||||
if why == "" {
|
||||
why = fmt.Sprintf("booted %s healthily, but not after a night step", k)
|
||||
}
|
||||
continue
|
||||
}
|
||||
out[k] = true
|
||||
}
|
||||
return out, why
|
||||
}
|
||||
|
||||
// kernelKey orders "7.0.14-22-pve" numerically: [7 0 14 22]. An unparsable version sorts first (never offered over a
|
||||
// parsable one).
|
||||
func kernelKey(k string) []int {
|
||||
var n []int
|
||||
for _, f := range strings.FieldsFunc(strings.TrimSuffix(k, "-pve"), func(r rune) bool { return r == '.' || r == '-' }) {
|
||||
v, err := strconv.Atoi(f)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
n = append(n, v)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func kernelLess(a, b string) bool {
|
||||
x, y := kernelKey(a), kernelKey(b)
|
||||
for i := 0; i < len(x) && i < len(y); i++ {
|
||||
if x[i] != y[i] {
|
||||
return x[i] < y[i]
|
||||
}
|
||||
}
|
||||
if len(x) != len(y) {
|
||||
return len(x) < len(y)
|
||||
}
|
||||
return a < b
|
||||
}
|
||||
|
||||
func newestKernel(set map[string]bool) string {
|
||||
best := ""
|
||||
for k := range set {
|
||||
if best == "" || kernelLess(best, k) {
|
||||
best = k
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func sortedKernels(set map[string]bool) []string {
|
||||
var l []string
|
||||
for k := range set {
|
||||
l = append(l, k)
|
||||
}
|
||||
sort.Slice(l, func(i, j int) bool { return kernelLess(l[i], l[j]) })
|
||||
return l
|
||||
}
|
||||
|
||||
// kernelSet is the approved set for a kernel version: the series meta-package and the signed image.
|
||||
func kernelSet(kver string) []Package {
|
||||
m := kverRE.FindStringSubmatch(kver)
|
||||
@@ -292,9 +383,12 @@ func kernelSet(kver string) []Package {
|
||||
{Name: "proxmox-kernel-" + kver + "-signed", Version: v, Origin: "Proxmox Debian Repository"}}
|
||||
}
|
||||
|
||||
// KernelStatus is the kernel set ring 0 booted and whether the operator's button may approve it: every ring-0 box's
|
||||
// newest ended kernel step is "applied" (a healthy one-shot boot made it the default) for the SAME kernel, after a
|
||||
// night stage; none fell back or reverted.
|
||||
// KernelStatus is the kernel set ring 0 booted and whether the operator's button may approve it. Operator ruling
|
||||
// 2026-10-10 (`09` §3, `11` §5.11): the button offers the NEWEST kernel version that EVERY ring-0 box has booted
|
||||
// healthily at least once after a night stage — a box may have moved on to a newer kernel since. (Before: every box's
|
||||
// newest ended step had to be "applied" for the SAME kernel at the same moment, which a kernel released on three days
|
||||
// in one week could keep from ever happening.) Per box and per kernel, only the NEWEST ended step counts: a kernel
|
||||
// whose latest step fell back, self-reverted or failed is not in that box's set. Pinned by kernel_approval_test.go.
|
||||
func (s *Service) KernelStatus() (Status, error) {
|
||||
st := Status{Layer: LayerKernel}
|
||||
ring0, err := s.ring0Hosts()
|
||||
@@ -302,52 +396,37 @@ func (s *Service) KernelStatus() (Status, error) {
|
||||
st.Waiting = "no ring-0 box"
|
||||
return st, err
|
||||
}
|
||||
kver := ""
|
||||
var common map[string]bool
|
||||
var perBox []string
|
||||
for _, h := range ring0 {
|
||||
reps, err := s.Store.OSReportsDesc(h, LayerKernel, 100)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
var applied Report
|
||||
var appliedAt time.Time
|
||||
for _, r := range reps {
|
||||
var kr Report
|
||||
if json.Unmarshal([]byte(r.ReportJSON), &kr) != nil {
|
||||
continue
|
||||
booted, why := healthyNightKernels(reps)
|
||||
if len(booted) == 0 {
|
||||
if why == "" {
|
||||
why = "has not booted a new kernel healthily yet"
|
||||
}
|
||||
if kernelEnded[r.Outcome] {
|
||||
if r.Outcome != "applied" || !r.Healthy {
|
||||
st.Waiting = fmt.Sprintf("%s: the kernel step to %s ended %s", h, kernelOf(kr), r.Outcome)
|
||||
return st, nil
|
||||
}
|
||||
applied, appliedAt = kr, r.ReceivedAt
|
||||
break
|
||||
}
|
||||
}
|
||||
k := kernelOf(applied)
|
||||
if k == "" {
|
||||
st.Waiting = h + " has not booted a new kernel healthily yet"
|
||||
st.Waiting = h + " " + why
|
||||
return st, nil
|
||||
}
|
||||
night := false
|
||||
for _, r := range reps {
|
||||
var kr Report
|
||||
if json.Unmarshal([]byte(r.ReportJSON), &kr) == nil && r.Outcome == "staged" && kr.Trigger == "night" &&
|
||||
kernelOf(kr) == k && !r.ReceivedAt.After(appliedAt) {
|
||||
night = true
|
||||
break
|
||||
perBox = append(perBox, h+": "+strings.Join(sortedKernels(booted), ", "))
|
||||
if common == nil {
|
||||
common = booted
|
||||
continue
|
||||
}
|
||||
for k := range common {
|
||||
if !booted[k] {
|
||||
delete(common, k)
|
||||
}
|
||||
}
|
||||
if !night {
|
||||
st.Waiting = fmt.Sprintf("%s booted %s healthily, but not after a night step", h, k)
|
||||
return st, nil
|
||||
}
|
||||
if kver != "" && k != kver {
|
||||
st.Waiting = fmt.Sprintf("the ring-0 boxes booted different kernels (%s, %s)", kver, k)
|
||||
return st, nil
|
||||
}
|
||||
kver = k
|
||||
}
|
||||
if len(common) == 0 {
|
||||
st.Waiting = "no kernel every ring-0 box booted healthily (" + strings.Join(perBox, "; ") + ")"
|
||||
return st, nil
|
||||
}
|
||||
kver := newestKernel(common)
|
||||
set := kernelSet(kver)
|
||||
c := map[string]Package{}
|
||||
for _, p := range set {
|
||||
@@ -392,14 +471,14 @@ func (s *Service) candidatePackages(fp string) string {
|
||||
|
||||
// KernelLine is one box's kernel view for the System page.
|
||||
type KernelLine struct {
|
||||
Due, Why string // the kernel it is due to step to, or why none
|
||||
LastOutcome string
|
||||
LastKernel string
|
||||
LastAt time.Time
|
||||
LastReason string
|
||||
NoticeAt time.Time // the newest day-before mail (any kernel)
|
||||
NoticeKver string
|
||||
Tonight bool
|
||||
Due, Why string // the kernel it is due to step to, or why none
|
||||
LastOutcome string
|
||||
LastKernel string
|
||||
LastAt time.Time
|
||||
LastReason string
|
||||
NoticeAt time.Time // the newest day-before mail (any kernel)
|
||||
NoticeKver string
|
||||
Tonight bool
|
||||
}
|
||||
|
||||
// KernelLineFor reads one box's kernel line.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package osupdates
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Operator ruling 2026-10-10 (`09` §3, `11` §5.11): "Approve kernel set" offers the NEWEST kernel version that EVERY
|
||||
// ring-0 box booted healthily at least once after a night step — a box may have moved on since. Before, every box's
|
||||
// newest step had to be the SAME kernel, and with kernels released on three days in one week (demo-felhom -22 then
|
||||
// -23, demo-hp -22) the button showed „the ring-0 boxes booted different kernels" and could chase a moving kernel for
|
||||
// ever. COMPANION RED-PROOF (observed): the pre-ruling KernelStatus fails the -22/-23 cases below.
|
||||
|
||||
func kstep(outcome, trigger string, healthy bool, from, to string) Report {
|
||||
return Report{Layer: LayerKernel, Trigger: trigger, Mode: "apply", Outcome: outcome, Healthy: healthy, ReleaseID: to,
|
||||
Kernel: json.RawMessage(`{"from":"` + from + `","to":"` + to + `"}`)}
|
||||
}
|
||||
|
||||
const k20, k22, k23 = "7.0.14-20-pve", "7.0.14-22-pve", "7.0.14-23-pve"
|
||||
|
||||
func (f *fix) booted(t *testing.T, host, from, to string) {
|
||||
t.Helper()
|
||||
f.ingest(t, host, kstep("staged", "night", true, from, to))
|
||||
f.ingest(t, host, kstep("applied", "boot", true, from, to))
|
||||
}
|
||||
|
||||
func offered(t *testing.T, f *fix) string {
|
||||
t.Helper()
|
||||
st, err := f.s.KernelStatus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Waiting != "" || st.Fingerprint == "" {
|
||||
return "WAITING: " + st.Waiting
|
||||
}
|
||||
pj := f.s.candidatePackages(st.Fingerprint)
|
||||
for _, k := range []string{k20, k22, k23} {
|
||||
if strings.Contains(pj, "proxmox-kernel-"+k+"-signed") {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return "UNKNOWN: " + pj
|
||||
}
|
||||
|
||||
func TestKernelApproval_OffersNewestEveryBoxBooted(t *testing.T) {
|
||||
f := newFix(t)
|
||||
f.booted(t, "n100", k20, k22)
|
||||
f.booted(t, "n100", k22, k23) // moved on
|
||||
f.booted(t, "hp", k20, k22)
|
||||
if got := offered(t, f); got != k22 {
|
||||
t.Fatalf("n100 booted -22,-23 and hp booted -22: offered %q, want %s", got, k22)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelApproval_BothBootedNewerOffersNewer(t *testing.T) {
|
||||
f := newFix(t)
|
||||
f.booted(t, "n100", k20, k22)
|
||||
f.booted(t, "n100", k22, k23)
|
||||
f.booted(t, "hp", k20, k22)
|
||||
f.booted(t, "hp", k22, k23)
|
||||
if got := offered(t, f); got != k23 {
|
||||
t.Fatalf("both booted -23: offered %q, want %s", got, k23)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelApproval_ABoxThatNeverBootedOneBlocks(t *testing.T) {
|
||||
f := newFix(t)
|
||||
f.booted(t, "n100", k20, k22)
|
||||
got := offered(t, f)
|
||||
if !strings.HasPrefix(got, "WAITING: hp ") {
|
||||
t.Fatalf("hp never booted a new kernel: offered %q, want waiting", got)
|
||||
}
|
||||
if _, err := f.s.ApproveKernel(); err == nil {
|
||||
t.Fatal("approved while one ring-0 box never booted a new kernel")
|
||||
}
|
||||
}
|
||||
|
||||
// A kernel whose NEWEST step on a box failed is not in that box's set; an older healthy kernel still is.
|
||||
func TestKernelApproval_AFailedLatestStepDropsThatKernelOnly(t *testing.T) {
|
||||
f := newFix(t)
|
||||
f.booted(t, "n100", k20, k22)
|
||||
f.booted(t, "n100", k22, k23)
|
||||
f.booted(t, "hp", k20, k22)
|
||||
f.booted(t, "hp", k22, k23)
|
||||
f.ingest(t, "hp", kstep("staged", "night", true, k22, k23))
|
||||
f.ingest(t, "hp", kstep("fell_back", "boot", false, k22, k23))
|
||||
if got := offered(t, f); got != k22 {
|
||||
t.Fatalf("-23 fell back on hp (its newest step): offered %q, want %s", got, k22)
|
||||
}
|
||||
}
|
||||
|
||||
// A healthy boot without a night stage does not count (the night rule is unchanged).
|
||||
func TestKernelApproval_ByDayBootDoesNotCount(t *testing.T) {
|
||||
f := newFix(t)
|
||||
f.booted(t, "n100", k20, k22)
|
||||
f.ingest(t, "hp", kstep("staged", "signed", true, k20, k22))
|
||||
f.ingest(t, "hp", kstep("applied", "boot", true, k20, k22))
|
||||
if got := offered(t, f); !strings.Contains(got, "not after a night step") {
|
||||
t.Fatalf("offered %q, want waiting on the night step", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKernelKeyOrdersNumerically(t *testing.T) {
|
||||
if !kernelLess("7.0.14-9-pve", "7.0.14-22-pve") || !kernelLess("7.0.14-22-pve", "7.1.0-1-pve") || kernelLess(k23, k22) {
|
||||
t.Fatal("kernel versions must order numerically, not as text")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user