c5f91174f6
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
212 lines
7.1 KiB
Go
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
|
|
}
|