Files
felhom.eu/hub/internal/store/os_updates.go
T

416 lines
15 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 ''`)
return 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
}
// 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) }