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

212 lines
7.1 KiB
Go

package store
import (
"database/sql"
"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
);
`)
return err
}
// 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
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.
res, err := s.db.Exec(`INSERT INTO os_reports (host_id, received_at, trigger, mode, outcome, healthy, release_id, report_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
r.HostID, 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, &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, received_at, trigger, mode, outcome, healthy, release_id, report_json`
// LatestOSReport returns the box's newest run, or nil.
func (s *Store) LatestOSReport(hostID string) (*OSReport, error) {
rows, err := s.db.Query(`SELECT `+osReportCols+` FROM os_reports WHERE host_id = ? ORDER BY id DESC LIMIT 1`, hostID)
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 received at or after t, oldest first.
func (s *Store) OSReportsSince(hostID string, t time.Time) ([]OSReport, error) {
rows, err := s.db.Query(`SELECT `+osReportCols+` FROM os_reports WHERE host_id = ? AND received_at >= ? ORDER BY id`,
hostID, 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
Fingerprint string
ApprovedAt time.Time
ApprovedBy string
PackagesJSON string
}
// SaveOSRelease stores an approved release.
func (s *Store) SaveOSRelease(r OSRelease) error {
_, err := s.db.Exec(`INSERT INTO os_releases (id, fingerprint, approved_at, approved_by, packages_json) VALUES (?, ?, ?, ?, ?)`,
r.ID, r.Fingerprint, r.ApprovedAt.UTC().Format("2006-01-02 15:04:05"), r.ApprovedBy, r.PackagesJSON)
return err
}
// LatestOSRelease returns the newest approved release, or nil.
func (s *Store) LatestOSRelease() (*OSRelease, error) {
var r OSRelease
var at string
err := s.db.QueryRow(`SELECT id, fingerprint, approved_at, approved_by, packages_json FROM os_releases ORDER BY approved_at DESC, id DESC LIMIT 1`).
Scan(&r.ID, &r.Fingerprint, &at, &r.ApprovedBy, &r.PackagesJSON)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
r.ApprovedAt = parseSQLiteTime(at)
return &r, nil
}
// 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
}