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

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
}