Files
felhom.eu/hub/internal/osupdates/kernel.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

425 lines
16 KiB
Go

package osupdates
// The kernel lane, hub half (hub v0.143.0; R-836, `09` §3 decision 172, `11` §5.11). The agent stages a kernel, boots
// it ONCE through a flag on the ESP, and makes it the default only after a healthy boot (felhom-agent internal/osupdate
// kernel.go + the wrapper's layer "kernel"). The hub decides WHEN:
//
// - A box is DUE when ring 0 has a pending kernel (its host report's `proxmox-kernel-X.Y` upgrade), or a ring-1 box
// runs a kernel a signed os_kernel_step STAGED — and no step for that kernel has ended yet (an ended step is the
// operator's to judge; the hub never retries it by itself).
// - The household of a due box gets ONE mail the day before, in its language, between 09:00 and 20:00 Budapest
// time (KernelNotify): the box restarts tonight; if it is not back by morning, unplug it, wait 10 seconds, plug it
// 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.
//
// Pinned by kernel_test.go.
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"gitea.dooplex.hu/admin/felhom-hub/internal/sysfacts"
)
// LayerKernel is the host's kernel (agent v0.152.0).
const LayerKernel = "kernel"
// Kernel-lane events — operator only (the household's one word is the day-before mail).
const (
EventKernelStep = "os_kernel_step" // what became of a kernel step (staged, the default moved, fell back, …)
EventKernelNotice = "os_kernel_notice" // the household was told — or could not be told (then no step)
)
// The day-before mail's window and limits (decided by CC — operator may reverse): mailed only in the household's
// daytime, valid for the night that follows, never twice within 20 h, at most 3 times for one kernel on one box.
const (
KernelNoticeFromHour = 9
KernelNoticeToHour = 20
KernelNoticeValid = 24 * time.Hour
KernelNoticeGap = 20 * time.Hour
KernelNoticeMax = 3
)
// KernelBlock is a box's kernel instruction (felhom-agent hub.WireKernelStep — field-exact, cross-repo).
type KernelBlock struct {
Kver string `json:"kver"`
Tonight bool `json:"tonight"`
NotifiedAt string `json:"notified_at,omitempty"`
}
var (
kverRE = regexp.MustCompile(`^([0-9]+\.[0-9]+)\.[0-9]+-[0-9]+-pve$`)
kernelMetaRE = regexp.MustCompile(`^proxmox-kernel-[0-9]+\.[0-9]+$`)
)
// kernelEnded are the outcomes after which a kernel is never stepped again by itself (the operator decides).
var kernelEnded = map[string]bool{"applied": true, "fell_back": true, "health_failed": true, "self_reverted": true, "revert_failed": true}
// refusals that will not go away by waiting a night (the box cannot do a one-shot, the set is wrong, no authority,
// not an appliance) — such a refusal ends the step too. A transient one (a lock, the crash guard's window) is retried
// within KernelNoticeMax mails.
var kernelRefusedForGood = map[string]bool{"R20": true, "R23": true, "R3": true, "R12": true}
func budapestLoc() *time.Location {
if l, err := time.LoadLocation("Europe/Budapest"); err == nil {
return l
}
return time.FixedZone("CET", 3600)
}
// kernelOf is the kernel a kernel-layer report is about: the wrapper's view's "to", else the release id.
func kernelOf(r Report) string {
var v struct {
To string `json:"to"`
}
if len(r.Kernel) > 0 && json.Unmarshal(r.Kernel, &v) == nil && kverRE.MatchString(v.To) {
return v.To
}
if kverRE.MatchString(r.ReleaseID) {
return r.ReleaseID
}
return ""
}
func refusedCode(raw json.RawMessage) string {
var c struct {
Code string `json:"code"`
}
_ = json.Unmarshal(raw, &c)
return c.Code
}
// kernelLane is the box's newest kernel-lane facts (nil: an older agent, or no host report).
func (s *Service) kernelLane(hostID string) *sysfacts.KernelLane {
h, err := s.Store.GetHost(hostID)
if err != nil || h == nil {
return nil
}
rj, _ := s.Store.GetLatestHostReportJSON(h.CustomerID)
if rj == "" {
return nil
}
return sysfacts.Parse(rj).Host.KernelLane
}
// KernelDue says which kernel the box is due to step to, or why none.
func (s *Service) KernelDue(hostID string) (kver, why string) {
st := s.Store.GetOSHostSettings(hostID)
if !st.Enabled {
return "", "OS updates are switched off"
}
rep, _ := s.Store.LatestOSReport(hostID, LayerHost)
if rep == nil {
return "", "no host step reported (the kernel lane is for appliances)"
}
lane := s.kernelLane(hostID)
if lane == nil {
return "", "the box reports no kernel lane (agent older than v0.152.0)"
}
if len(lane.SetupProblems) > 0 {
return "", "the box cannot do a one-shot boot: " + strings.Join(lane.SetupProblems, "; ")
}
if st.Ring == 1 {
if lane.Phase != "staged" || !kverRE.MatchString(lane.To) {
return "", "ring 1: no kernel staged by a signed os_kernel_step"
}
kver = lane.To
} else {
var hr Report
_ = json.Unmarshal([]byte(rep.ReportJSON), &hr)
for _, p := range hr.Pending {
if kernelMetaRE.MatchString(p.Name) && p.From != "" && kverRE.MatchString(p.To+"-pve") {
kver = p.To + "-pve"
}
}
if kver == "" && lane.Phase == "staged" && kverRE.MatchString(lane.To) {
kver = lane.To
}
if kver == "" {
return "", "no pending kernel"
}
}
if lane.Running == kver {
return "", "the box already runs " + kver
}
reps, _ := s.Store.OSReportsDesc(hostID, LayerKernel, 100)
for _, r := range reps {
var kr Report
if json.Unmarshal([]byte(r.ReportJSON), &kr) != nil || kernelOf(kr) != kver {
continue
}
if kernelEnded[r.Outcome] {
return "", fmt.Sprintf("the kernel step to %s ended %s at %s — the operator decides", kver, r.Outcome,
r.ReceivedAt.UTC().Format("2006-01-02 15:04"))
}
if r.Outcome == "refused" && kernelRefusedForGood[refusedCode(kr.Refused)] {
return "", fmt.Sprintf("the kernel step to %s was refused (%s) — the operator decides", kver, refusedCode(kr.Refused))
}
}
return kver, ""
}
// KernelBlockFor is the box's kernel instruction (nil: not due).
func (s *Service) KernelBlockFor(hostID string) *KernelBlock {
kver, _ := s.KernelDue(hostID)
if kver == "" {
return nil
}
b := &KernelBlock{Kver: kver}
ns, _ := s.Store.KernelNotices(hostID, kver)
if len(ns) > 0 {
last := ns[len(ns)-1].SentAt
if s.now().Sub(last) < KernelNoticeValid {
b.Tonight, b.NotifiedAt = true, last.UTC().Format(time.RFC3339)
}
}
return b
}
// KernelNotify tells each due box's household, the day before (`09` §3 decision 172). Called every minute; it acts only
// between KernelNoticeFromHour and KernelNoticeToHour Budapest time. Returns the boxes it told.
func (s *Service) KernelNotify() []string {
now := s.now()
lt := now.In(budapestLoc())
if lt.Hour() < KernelNoticeFromHour || lt.Hour() >= KernelNoticeToHour {
return nil
}
hosts, err := s.Store.ListHosts()
if err != nil {
return nil
}
var told []string
for _, h := range hosts {
kver, _ := s.KernelDue(h.HostID)
if kver == "" {
continue
}
ns, _ := s.Store.KernelNotices(h.HostID, kver)
if len(ns) > 0 && now.Sub(ns[len(ns)-1].SentAt) < KernelNoticeGap {
continue
}
if len(ns) >= KernelNoticeMax {
key := "kernel_notice_max:" + h.HostID + ":" + kver
if s.Store.OSAlarmRaised(key).IsZero() {
_ = s.Store.SetOSAlarmRaised(key, now)
s.event(h.CustomerID, EventKernelNotice, "warning", fmt.Sprintf("Kernel %s on %s: the household was told %d times "+
"and no kernel step ran — no further mail; the operator decides (the System page shows why).", kver, h.HostID, len(ns)),
map[string]any{"host_id": h.HostID, "kver": kver, "notices": len(ns)})
}
continue
}
if s.KernelMail == nil {
continue
}
lang, err := s.KernelMail(h.CustomerID, kver)
if err != nil {
key := "kernel_notice_failed:" + h.HostID + ":" + kver + ":" + lt.Format("2006-01-02")
if s.Store.OSAlarmRaised(key).IsZero() {
_ = s.Store.SetOSAlarmRaised(key, now)
s.event(h.CustomerID, EventKernelNotice, "warning", fmt.Sprintf("Kernel %s on %s: the household could NOT be told "+
"(%v) — so no kernel step tonight (no mail, no step).", kver, h.HostID, err),
map[string]any{"host_id": h.HostID, "kver": kver, "error": err.Error()})
}
continue
}
if err := s.Store.SaveKernelNotice(store.KernelNotice{HostID: h.HostID, Kver: kver, SentAt: now, Lang: lang}); err != nil {
s.logf("[ERROR] osupdates: kernel notice for %s sent but NOT recorded (%v) — no step tonight", h.HostID, err)
continue
}
told = append(told, h.HostID)
s.logf("[INFO] osupdates: kernel %s — the household of %s was told the box restarts tonight (lang=%s)", kver, h.HostID, lang)
s.event(h.CustomerID, EventKernelNotice, "info", fmt.Sprintf("Kernel %s on %s: the household was told the box "+
"restarts tonight (mail %d of at most %d).", kver, h.HostID, len(ns)+1, KernelNoticeMax),
map[string]any{"host_id": h.HostID, "kver": kver, "lang": lang})
if s.Bump != nil {
s.Bump(h.HostID)
}
}
sort.Strings(told)
return told
}
// kernelIngest raises the kernel layer's operator events (called by Ingest for layer "kernel").
func (s *Service) kernelIngest(hostID, customerID string, r Report, details map[string]any) {
var v struct {
From, To, Reason string
}
_ = json.Unmarshal(r.Kernel, &v)
to := kernelOf(r)
details["kver"] = to
switch r.Outcome {
case "staged":
s.event(customerID, EventKernelStep, "info", fmt.Sprintf("Kernel %s staged on %s (trigger %s): installed, never the "+
"default; the box boots it ONCE at its night reboot.", to, hostID, r.Trigger), details)
case "applied":
s.event(customerID, EventKernelStep, "info", fmt.Sprintf("Kernel %s booted healthily on %s and is the default now "+
"(was %s). %s", to, hostID, v.From, r.HealthReason), details)
s.event(customerID, EventApplied, "info", "System security fixes installed on the box's kernel (the box restarted at night).", details)
case "fell_back":
s.event(customerID, EventKernelStep, "error", fmt.Sprintf("Kernel %s did NOT come up on %s: the box came back on %s "+
"by itself. %s is installed but never the default; the operator decides.", to, hostID, v.From, to), details)
case "health_failed":
s.event(customerID, EventKernelStep, "error", fmt.Sprintf("Kernel %s booted on %s but is NOT healthy (%s): the box "+
"restarts ONCE into %s by itself.", to, hostID, r.HealthReason, v.From), details)
case "self_reverted":
s.event(customerID, EventKernelStep, "error", fmt.Sprintf("Kernel %s on %s: back on %s after the self-revert (%s). "+
"The operator decides.", to, hostID, v.From, r.HealthReason), details)
case "revert_failed":
s.event(customerID, EventKernelStep, "critical", fmt.Sprintf("Kernel %s on %s: the self-revert did NOT bring back %s "+
"(%s). No second revert — look at the box.", to, hostID, v.From, r.HealthReason), details)
case "refused", "failed":
s.event(customerID, EventFailed, "error", fmt.Sprintf("Kernel step on %s %s: %s", hostID, r.Outcome,
strings.TrimSpace(string(r.Refused)+" "+r.HealthReason)), details)
}
}
// 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)
if m == nil {
return nil
}
v := strings.TrimSuffix(kver, "-pve")
return []Package{{Name: "proxmox-kernel-" + m[1], Version: v, Origin: "Proxmox Debian Repository"},
{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.
func (s *Service) KernelStatus() (Status, error) {
st := Status{Layer: LayerKernel}
ring0, err := s.ring0Hosts()
if err != nil || len(ring0) == 0 {
st.Waiting = "no ring-0 box"
return st, err
}
kver := ""
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
}
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"
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
}
}
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
}
set := kernelSet(kver)
c := map[string]Package{}
for _, p := range set {
c[p.Name] = p
}
fp, list := fingerprint(LayerKernel, c)
pj, _ := json.Marshal(list)
first, err := s.Store.OSCandidateFirstSeen(fp, string(pj), s.now())
if err != nil {
return st, err
}
st.Fingerprint, st.FirstSeen, st.Packages = fp, first, len(list)
if rel, _ := s.Store.LatestOSRelease(LayerKernel); rel != nil && rel.Fingerprint == fp {
st.Approved, st.Waiting = rel.ID, "already approved"
}
return st, nil
}
// ApproveKernel is the operator's "Approve kernel set" (`09` §3 decision 172). A ring-1 box takes it only through a
// signed os_kernel_step — approval alone installs nothing and restarts nothing.
func (s *Service) ApproveKernel() (string, error) {
st, err := s.KernelStatus()
if err != nil {
return "", err
}
if st.Waiting != "" || st.Fingerprint == "" {
return "", fmt.Errorf("osupdates: the kernel set cannot be approved yet: %s", st.Waiting)
}
var list []Package
_ = json.Unmarshal([]byte(s.candidatePackages(st.Fingerprint)), &list)
if err := s.approve(LayerKernel, st.Fingerprint, list, "operator"); err != nil {
return "", err
}
rel, _ := s.Store.LatestOSRelease(LayerKernel)
return rel.ID, nil
}
func (s *Service) candidatePackages(fp string) string {
pj, _ := s.Store.OSCandidatePackages(fp)
return pj
}
// 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
}
// KernelLineFor reads one box's kernel line.
func (s *Service) KernelLineFor(hostID string) KernelLine {
var l KernelLine
l.Due, l.Why = s.KernelDue(hostID)
if b := s.KernelBlockFor(hostID); b != nil {
l.Tonight = b.Tonight
}
if rep, _ := s.Store.LatestOSReport(hostID, LayerKernel); rep != nil {
var kr Report
_ = json.Unmarshal([]byte(rep.ReportJSON), &kr)
l.LastOutcome, l.LastKernel, l.LastAt, l.LastReason = rep.Outcome, kernelOf(kr), rep.ReceivedAt, kr.HealthReason
if l.LastReason == "" && len(kr.Refused) > 0 {
l.LastReason = string(kr.Refused)
}
}
if n, _ := s.Store.LatestKernelNotice(hostID); n != nil {
l.NoticeAt, l.NoticeKver = n.SentAt, n.Kver
}
return l
}