88b0a2e761
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
316 lines
11 KiB
Go
316 lines
11 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'`)
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SaveOSRelease stores an approved release.
|
|
func (s *Store) SaveOSRelease(r OSRelease) error {
|
|
layer := r.Layer
|
|
if layer == "" {
|
|
layer = "guest"
|
|
}
|
|
_, err := s.db.Exec(`INSERT INTO os_releases (id, layer, fingerprint, approved_at, approved_by, packages_json) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
r.ID, layer, r.Fingerprint, r.ApprovedAt.UTC().Format("2006-01-02 15:04:05"), r.ApprovedBy, r.PackagesJSON)
|
|
return err
|
|
}
|
|
|
|
// LatestOSRelease returns the newest approved release of a layer, or nil.
|
|
func (s *Store) LatestOSRelease(layer string) (*OSRelease, error) {
|
|
var r OSRelease
|
|
var at string
|
|
err := s.db.QueryRow(`SELECT id, layer, fingerprint, approved_at, approved_by, packages_json FROM os_releases WHERE layer = ? ORDER BY approved_at DESC, id DESC LIMIT 1`, layer).
|
|
Scan(&r.ID, &r.Layer, &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
|
|
}
|
|
|
|
// 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
|
|
}
|