Files
felhom.eu/hub/internal/store/os_updates.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

487 lines
18 KiB
Go

package store
import (
"database/sql"
"strings"
"time"
)
// OS updates, guest fast lane (`11-os-updates.md` §5.3, §8 step 2; hub v0.130.0).
//
// Three records:
// - os_host_settings: the operator's per-box ring (0 = demo boxes that take every update first, 1 = every other
// box) and switch (ON by default — decision 12's shape). A host with no row is ring 1, ON.
// - os_reports: every run the agent reports (the full installed set rides in report_json — C9: what ring 0 RUNS).
// - os_candidates / os_releases: the version set ring 0 runs, first seen when, and the approved releases.
func (s *Store) migrateOSUpdates() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS os_host_settings (
host_id TEXT PRIMARY KEY,
ring INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS os_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id TEXT NOT NULL,
received_at DATETIME NOT NULL DEFAULT (datetime('now')),
trigger TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL DEFAULT '',
healthy INTEGER NOT NULL DEFAULT 0,
release_id TEXT NOT NULL DEFAULT '',
report_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_os_reports_host ON os_reports(host_id, id);
CREATE TABLE IF NOT EXISTS os_candidates (
fingerprint TEXT PRIMARY KEY,
first_seen DATETIME NOT NULL,
packages_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS os_releases (
id TEXT PRIMARY KEY,
fingerprint TEXT NOT NULL,
approved_at DATETIME NOT NULL,
approved_by TEXT NOT NULL,
packages_json TEXT NOT NULL
);
`)
if err != nil {
return err
}
// Host fast lane (hub v0.131.0): every report and release belongs to a LAYER; old rows are the guest's.
s.db.Exec(`ALTER TABLE os_reports ADD COLUMN layer TEXT NOT NULL DEFAULT 'guest'`)
s.db.Exec(`ALTER TABLE os_releases ADD COLUMN layer TEXT NOT NULL DEFAULT 'guest'`)
// Test approvals end with the test (hub v0.133.0, `11` §5.3.1): an approval made while a TEST override is active
// carries `test`; a start without the override CANCELS every test approval no real one has superseded.
if !s.hasColumn("os_releases", "test") {
if _, err := s.db.Exec(`ALTER TABLE os_releases ADD COLUMN test INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
// One-time backfill, from the data: an AUTOMATIC approval earlier than 24 h after its set was first seen
// cannot have passed the ruled wait (24 h + one night) — it was a TEST approval (2026-10-04: the guest and host
// sets, approved 1.5 h after first seen under OS_APPROVE_*). The operator's own Docker button approval of that day
// is NOT backfilled: a person approved it; the override only shortened its precondition (decided by CC
// unattended — operator may reverse). From hub v0.133.0 on, every approval under an override is marked.
s.db.Exec(`UPDATE os_releases SET test = 1 WHERE approved_by = 'auto' AND EXISTS (SELECT 1 FROM os_candidates c
WHERE c.fingerprint = os_releases.fingerprint
AND (julianday(os_releases.approved_at) - julianday(c.first_seen)) * 24 < 24)`)
}
s.db.Exec(`ALTER TABLE os_releases ADD COLUMN cancelled_at TEXT NOT NULL DEFAULT ''`)
s.db.Exec(`ALTER TABLE os_releases ADD COLUMN cancel_reason TEXT NOT NULL DEFAULT ''`)
// The kernel lane (hub v0.143.0, R-836, `09` §3 decision 172): every mail that told a household "the box restarts
// tonight". A kernel step runs only on a night such a mail announced — no mail, no step.
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS os_kernel_notices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id TEXT NOT NULL,
kver TEXT NOT NULL,
sent_at DATETIME NOT NULL,
lang TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_os_kernel_notices ON os_kernel_notices(host_id, kver, id);`); err != nil {
return err
}
return nil
}
// KernelNotice is one "the box restarts tonight" mail that reached the mail service.
type KernelNotice struct {
HostID, Kver, Lang string
SentAt time.Time
}
// SaveKernelNotice records a sent notice (only after the mail service accepted it).
func (s *Store) SaveKernelNotice(n KernelNotice) error {
_, err := s.db.Exec(`INSERT INTO os_kernel_notices (host_id, kver, sent_at, lang) VALUES (?, ?, ?, ?)`,
n.HostID, n.Kver, n.SentAt.UTC().Format("2006-01-02 15:04:05"), n.Lang)
return err
}
// KernelNotices lists the notices for one box and kernel, oldest first.
func (s *Store) KernelNotices(hostID, kver string) ([]KernelNotice, error) {
rows, err := s.db.Query(`SELECT host_id, kver, sent_at, lang FROM os_kernel_notices WHERE host_id = ? AND kver = ? ORDER BY id`, hostID, kver)
if err != nil {
return nil, err
}
defer rows.Close()
var out []KernelNotice
for rows.Next() {
var n KernelNotice
var at string
if err := rows.Scan(&n.HostID, &n.Kver, &at, &n.Lang); err != nil {
return nil, err
}
n.SentAt = parseSQLiteTime(at)
out = append(out, n)
}
return out, rows.Err()
}
// LatestKernelNotice is the newest notice for a box (any kernel); nil when there was none.
func (s *Store) LatestKernelNotice(hostID string) (*KernelNotice, error) {
var n KernelNotice
var at string
err := s.db.QueryRow(`SELECT host_id, kver, sent_at, lang FROM os_kernel_notices WHERE host_id = ? ORDER BY id DESC LIMIT 1`, hostID).
Scan(&n.HostID, &n.Kver, &at, &n.Lang)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
n.SentAt = parseSQLiteTime(at)
return &n, nil
}
// hasColumn reports whether table has the column (PRAGMA table_info).
func (s *Store) hasColumn(table, col string) bool {
rows, err := s.db.Query(`SELECT name FROM pragma_table_info(?)`, table)
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var n string
if rows.Scan(&n) == nil && n == col {
return true
}
}
return false
}
// OSHostSettings is one box's ring and switch.
type OSHostSettings struct {
HostID string
Ring int
Enabled bool
}
// GetOSHostSettings returns the box's settings; a box with no row is ring 1, ON.
func (s *Store) GetOSHostSettings(hostID string) OSHostSettings {
st := OSHostSettings{HostID: hostID, Ring: 1, Enabled: true}
var ring, en int
if err := s.db.QueryRow(`SELECT ring, enabled FROM os_host_settings WHERE host_id = ?`, hostID).Scan(&ring, &en); err == nil {
st.Ring, st.Enabled = ring, en == 1
}
return st
}
// SetOSRing sets the box's ring (0 or 1).
func (s *Store) SetOSRing(hostID string, ring int) error {
_, err := s.db.Exec(`INSERT INTO os_host_settings (host_id, ring) VALUES (?, ?)
ON CONFLICT(host_id) DO UPDATE SET ring = excluded.ring, updated_at = datetime('now')`, hostID, ring)
return err
}
// SetOSEnabled sets the box's switch.
func (s *Store) SetOSEnabled(hostID string, on bool) error {
v := 0
if on {
v = 1
}
_, err := s.db.Exec(`INSERT INTO os_host_settings (host_id, enabled) VALUES (?, ?)
ON CONFLICT(host_id) DO UPDATE SET enabled = excluded.enabled, updated_at = datetime('now')`, hostID, v)
return err
}
// OSReport is one stored run.
type OSReport struct {
ID int64
HostID string
Layer string // guest | host
ReceivedAt time.Time
Trigger string
Mode string
Outcome string
Healthy bool
ReleaseID string
ReportJSON string
}
// SaveOSReport stores one run.
func (s *Store) SaveOSReport(r OSReport) (int64, error) {
h := 0
if r.Healthy {
h = 1
}
at := r.ReceivedAt
if at.IsZero() {
at = time.Now()
}
// received_at comes from the CALLER's clock: the approval rule compares it with first_seen, which the
// service stamps with its own clock — two clocks would make "since first seen" miss reports.
layer := r.Layer
if layer == "" {
layer = "guest"
}
res, err := s.db.Exec(`INSERT INTO os_reports (host_id, layer, received_at, trigger, mode, outcome, healthy, release_id, report_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.HostID, layer, at.UTC().Format("2006-01-02 15:04:05"), r.Trigger, r.Mode, r.Outcome, h, r.ReleaseID, r.ReportJSON)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func scanOSReports(rows *sql.Rows) ([]OSReport, error) {
defer rows.Close()
var out []OSReport
for rows.Next() {
var r OSReport
var at string
var h int
if err := rows.Scan(&r.ID, &r.HostID, &r.Layer, &at, &r.Trigger, &r.Mode, &r.Outcome, &h, &r.ReleaseID, &r.ReportJSON); err != nil {
return nil, err
}
r.ReceivedAt, r.Healthy = parseSQLiteTime(at), h == 1
out = append(out, r)
}
return out, rows.Err()
}
const osReportCols = `id, host_id, layer, received_at, trigger, mode, outcome, healthy, release_id, report_json`
// LatestOSReport returns the box's newest run for a layer, or nil.
func (s *Store) LatestOSReport(hostID, layer string) (*OSReport, error) {
rows, err := s.db.Query(`SELECT `+osReportCols+` FROM os_reports WHERE host_id = ? AND layer = ? ORDER BY id DESC LIMIT 1`, hostID, layer)
if err != nil {
return nil, err
}
rs, err := scanOSReports(rows)
if err != nil || len(rs) == 0 {
return nil, err
}
return &rs[0], nil
}
// OSReportsSince returns the box's runs of a layer received at or after t, oldest first.
func (s *Store) OSReportsSince(hostID, layer string, t time.Time) ([]OSReport, error) {
rows, err := s.db.Query(`SELECT `+osReportCols+` FROM os_reports WHERE host_id = ? AND layer = ? AND received_at >= ? ORDER BY id`,
hostID, layer, t.UTC().Format("2006-01-02 15:04:05"))
if err != nil {
return nil, err
}
return scanOSReports(rows)
}
// OSCandidateFirstSeen records a candidate set the first time it is seen and returns when that was.
func (s *Store) OSCandidateFirstSeen(fingerprint, packagesJSON string, now time.Time) (time.Time, error) {
if _, err := s.db.Exec(`INSERT OR IGNORE INTO os_candidates (fingerprint, first_seen, packages_json) VALUES (?, ?, ?)`,
fingerprint, now.UTC().Format("2006-01-02 15:04:05"), packagesJSON); err != nil {
return time.Time{}, err
}
var at string
if err := s.db.QueryRow(`SELECT first_seen FROM os_candidates WHERE fingerprint = ?`, fingerprint).Scan(&at); err != nil {
return time.Time{}, err
}
return parseSQLiteTime(at), nil
}
// OSCandidatePackages is the package list a candidate fingerprint was first seen with ("" when unknown).
func (s *Store) OSCandidatePackages(fingerprint string) (string, error) {
var pj string
err := s.db.QueryRow(`SELECT packages_json FROM os_candidates WHERE fingerprint = ?`, fingerprint).Scan(&pj)
if err == sql.ErrNoRows {
return "", nil
}
return pj, err
}
// OSRelease is one approved version set.
type OSRelease struct {
ID string
Layer string
Fingerprint string
ApprovedAt time.Time
ApprovedBy string
PackagesJSON string
Test bool // approved while a TEST override was active (`11` §5.3.1)
CancelledAt string // "" = in force; a cancelled release is never served (LatestOSRelease skips it)
CancelReason string
}
const osReleaseCols = `id, layer, fingerprint, approved_at, approved_by, packages_json, test, cancelled_at, cancel_reason`
func scanOSRelease(sc interface{ Scan(...any) error }) (*OSRelease, error) {
var r OSRelease
var at string
var test int
if err := sc.Scan(&r.ID, &r.Layer, &r.Fingerprint, &at, &r.ApprovedBy, &r.PackagesJSON, &test, &r.CancelledAt, &r.CancelReason); err != nil {
return nil, err
}
r.ApprovedAt, r.Test = parseSQLiteTime(at), test == 1
return &r, nil
}
// SaveOSRelease stores an approved release.
func (s *Store) SaveOSRelease(r OSRelease) error {
layer := r.Layer
if layer == "" {
layer = "guest"
}
test := 0
if r.Test {
test = 1
}
_, err := s.db.Exec(`INSERT INTO os_releases (id, layer, fingerprint, approved_at, approved_by, packages_json, test) VALUES (?, ?, ?, ?, ?, ?, ?)`,
r.ID, layer, r.Fingerprint, r.ApprovedAt.UTC().Format("2006-01-02 15:04:05"), r.ApprovedBy, r.PackagesJSON, test)
return err
}
// LatestOSRelease returns the newest approved release of a layer that is IN FORCE (not cancelled), or nil.
func (s *Store) LatestOSRelease(layer string) (*OSRelease, error) {
r, err := scanOSRelease(s.db.QueryRow(`SELECT `+osReleaseCols+` FROM os_releases WHERE layer = ? AND cancelled_at = ''
ORDER BY approved_at DESC, id DESC LIMIT 1`, layer))
if err == sql.ErrNoRows {
return nil, nil
}
return r, err
}
// UnsupersededTestReleases returns a layer's test releases still in force and newer than its newest REAL (non-test,
// in-force) release — the ones a start without the TEST override must cancel. Newest first.
func (s *Store) UnsupersededTestReleases(layer string) ([]OSRelease, error) {
rows, err := s.db.Query(`SELECT `+osReleaseCols+` FROM os_releases r WHERE r.layer = ? AND r.test = 1 AND r.cancelled_at = ''
AND r.approved_at >= COALESCE((SELECT MAX(approved_at) FROM os_releases WHERE layer = r.layer AND test = 0 AND cancelled_at = ''), '')
ORDER BY r.approved_at DESC, r.id DESC`, layer)
if err != nil {
return nil, err
}
defer rows.Close()
var out []OSRelease
for rows.Next() {
r, err := scanOSRelease(rows)
if err != nil {
return nil, err
}
out = append(out, *r)
}
return out, rows.Err()
}
// CancelOSRelease marks one release cancelled (it is never served again; its row stays as the record).
func (s *Store) CancelOSRelease(id, reason string, at time.Time) error {
_, err := s.db.Exec(`UPDATE os_releases SET cancelled_at = ?, cancel_reason = ? WHERE id = ? AND cancelled_at = ''`,
at.UTC().Format("2006-01-02 15:04:05"), reason, id)
return err
}
// CancelledOSReleasesSince lists releases cancelled at or after t (the System page shows them), newest first.
func (s *Store) CancelledOSReleasesSince(t time.Time) ([]OSRelease, error) {
rows, err := s.db.Query(`SELECT `+osReleaseCols+` FROM os_releases WHERE cancelled_at != '' AND cancelled_at >= ?
ORDER BY cancelled_at DESC, id DESC`, t.UTC().Format("2006-01-02 15:04:05"))
if err != nil {
return nil, err
}
defer rows.Close()
var out []OSRelease
for rows.Next() {
r, err := scanOSRelease(rows)
if err != nil {
return nil, err
}
out = append(out, *r)
}
return out, rows.Err()
}
// BackdateOSCandidateForTest moves a candidate's first_seen into the past. TEST-ONLY.
func (s *Store) BackdateOSCandidateForTest(fingerprint string, by time.Duration) error {
_, err := s.db.Exec(`UPDATE os_candidates SET first_seen = ? WHERE fingerprint = ?`,
time.Now().Add(-by).UTC().Format("2006-01-02 15:04:05"), fingerprint)
return err
}
// OSAlarmRaised returns when an OS alarm (by key) was last raised; zero = not raised / cleared (hub v0.131.0).
func (s *Store) OSAlarmRaised(key string) time.Time {
v := s.getSetting("os_alarm:" + key)
if v == "" {
return time.Time{}
}
t, _ := time.Parse(time.RFC3339, v)
return t
}
// SetOSAlarmRaised records (t non-zero) or clears (zero t) an OS alarm.
func (s *Store) SetOSAlarmRaised(key string, t time.Time) error {
v := ""
if !t.IsZero() {
v = t.UTC().Format(time.RFC3339)
}
return s.setSetting("os_alarm:"+key, v)
}
// HostAgentVersionsSeen returns, per agent version a host ever reported, the first time it was seen (the stale-leg
// alarm's start point for a box whose agent CAN run the OS leg but never reported one).
func (s *Store) HostAgentVersionsSeen(hostID string) (map[string]time.Time, error) {
rows, err := s.db.Query(`SELECT COALESCE(agent_version, ''), MIN(received_at) FROM host_reports WHERE host_id = ? GROUP BY agent_version`, hostID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]time.Time{}
for rows.Next() {
var v, at string
if err := rows.Scan(&v, &at); err != nil {
return nil, err
}
out[v] = parseSQLiteTime(at)
}
return out, rows.Err()
}
// LatestOSReportWhere returns the box's newest report of a layer whose outcome is one of outcomes and healthy, or nil.
func (s *Store) LatestHealthyOSReport(hostID, layer string, outcomes ...string) (*OSReport, error) {
q := `SELECT ` + osReportCols + ` FROM os_reports WHERE host_id = ? AND layer = ? AND healthy = 1 AND outcome IN (?` +
strings.Repeat(",?", len(outcomes)-1) + `) ORDER BY id DESC LIMIT 1`
args := []any{hostID, layer}
for _, o := range outcomes {
args = append(args, o)
}
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
rs, err := scanOSReports(rows)
if err != nil || len(rs) == 0 {
return nil, err
}
return &rs[0], nil
}
// OSReportsDesc returns the box's newest n reports of a layer, newest first.
func (s *Store) OSReportsDesc(hostID, layer string, n int) ([]OSReport, error) {
rows, err := s.db.Query(`SELECT `+osReportCols+` FROM os_reports WHERE host_id = ? AND layer = ? ORDER BY id DESC LIMIT ?`, hostID, layer, n)
if err != nil {
return nil, err
}
return scanOSReports(rows)
}
// FirstOSReport returns the oldest report of a layer from any of the given hosts, or nil.
func (s *Store) FirstOSReport(layer string, hosts []string) (*OSReport, error) {
if len(hosts) == 0 {
return nil, nil
}
q := `SELECT ` + osReportCols + ` FROM os_reports WHERE layer = ? AND host_id IN (?` + strings.Repeat(",?", len(hosts)-1) + `) ORDER BY id LIMIT 1`
args := []any{layer}
for _, h := range hosts {
args = append(args, h)
}
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
rs, err := scanOSReports(rows)
if err != nil || len(rs) == 0 {
return nil, err
}
return &rs[0], nil
}
// CrashSeen / SetCrashSeen keep, per host, what the hub already announced from the box's crash guard (hub v0.132.0):
// the unclean boots, the trip and the re-arm it has turned into events, so a report repeating them stays quiet.
func (s *Store) CrashSeen(hostID string) string { return s.getSetting("crash_seen:" + hostID) }
func (s *Store) SetCrashSeen(hostID, v string) error { return s.setSetting("crash_seen:"+hostID, v) }