gates / gates (push) Successful in 6m45s
Hub: the R-120 golden guard judges only a CHANGED golden selection; offsite_snapshots_vanished / _unexplained (operator-only, hub-origin) on the 07:10 listing; internal/accesslog writes path-only lines to /data/access-log, 90 days. Installer: --rotate-recovery-only runs step 4b alone (rides the next installer tag). 03: the orphan check runs after a failed backup (agent 6545ccc). 02: the allow-list is empty, appdata_folders (controller v0.329.0). All red-proofed.
97 lines
3.5 KiB
Go
97 lines
3.5 KiB
Go
package store
|
||
|
||
import (
|
||
"encoding/json"
|
||
"sort"
|
||
"time"
|
||
)
|
||
|
||
// R-895 slice 2 (hub v0.158.0): the hub's OWN listing of a box's off-site `snapshots/` directory, kept between two
|
||
// daily audits so the next one can tell which snapshot files vanished and which appeared. Names and upload times only
|
||
// — never contents (`07` §8a custody). One hub_settings row per customer (≈ 70 names × ~90 bytes), the same per-key
|
||
// pattern as ForeignKeyArchiveSignature.
|
||
|
||
const offsiteSnapshotListingKeyPrefix = "offsite_snapshot_listing:"
|
||
|
||
// OffsiteSnapshotListing is the stored baseline: when it was listed, and each snapshot id with its upload time.
|
||
type OffsiteSnapshotListing struct {
|
||
ListedAt time.Time `json:"listed_at"`
|
||
Entries map[string]time.Time `json:"entries"`
|
||
}
|
||
|
||
// GetOffsiteSnapshotListing returns the stored baseline; ok=false when there is none (or it does not parse — a
|
||
// baseline that cannot be read is no baseline, and no baseline means no verdict).
|
||
func (s *Store) GetOffsiteSnapshotListing(customerID string) (OffsiteSnapshotListing, bool) {
|
||
raw := s.getSetting(offsiteSnapshotListingKeyPrefix + customerID)
|
||
if raw == "" {
|
||
return OffsiteSnapshotListing{}, false
|
||
}
|
||
var l OffsiteSnapshotListing
|
||
if err := json.Unmarshal([]byte(raw), &l); err != nil || l.ListedAt.IsZero() {
|
||
return OffsiteSnapshotListing{}, false
|
||
}
|
||
if l.Entries == nil {
|
||
l.Entries = map[string]time.Time{}
|
||
}
|
||
return l, true
|
||
}
|
||
|
||
// SetOffsiteSnapshotListing replaces the stored baseline.
|
||
func (s *Store) SetOffsiteSnapshotListing(customerID string, l OffsiteSnapshotListing) error {
|
||
b, err := json.Marshal(l)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.setSetting(offsiteSnapshotListingKeyPrefix+customerID, string(b))
|
||
}
|
||
|
||
// ClearOffsiteSnapshotListing drops the baseline (the repository was set aside or purged — the next listing is a
|
||
// fresh baseline, never a comparison against a different repository).
|
||
func (s *Store) ClearOffsiteSnapshotListing(customerID string) error {
|
||
_, err := s.db.Exec(`DELETE FROM hub_settings WHERE key = ?`, offsiteSnapshotListingKeyPrefix+customerID)
|
||
return err
|
||
}
|
||
|
||
// OffsiteRunTimesSince returns the distinct off-site run times (`offsite.last_run`, written by the box at the END of
|
||
// every run — scheduled, manual, catch-up or retry) carried by the customer's controller reports received after
|
||
// `since`, oldest first. A run is reported in every report until the next run, so a 15-minute report cadence sees
|
||
// every run that is at least one report apart. Capped at 5000 reports (≈ 50 days at the measured cadence).
|
||
func (s *Store) OffsiteRunTimesSince(customerID string, since time.Time) ([]time.Time, error) {
|
||
rows, err := s.db.Query(`SELECT report_json FROM reports WHERE customer_id = ? AND received_at > ? ORDER BY id LIMIT 5000`,
|
||
customerID, since.UTC().Format("2006-01-02 15:04:05"))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
seen := map[time.Time]bool{}
|
||
var out []time.Time
|
||
for rows.Next() {
|
||
var reportJSON string
|
||
if err := rows.Scan(&reportJSON); err != nil {
|
||
return nil, err
|
||
}
|
||
var p struct {
|
||
Offsite *struct {
|
||
LastRun string `json:"last_run"`
|
||
} `json:"offsite"`
|
||
}
|
||
if json.Unmarshal([]byte(reportJSON), &p) != nil || p.Offsite == nil || p.Offsite.LastRun == "" {
|
||
continue
|
||
}
|
||
t, err := time.Parse(time.RFC3339, p.Offsite.LastRun)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
t = t.UTC()
|
||
if !seen[t] {
|
||
seen[t] = true
|
||
out = append(out, t)
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Before(out[j]) })
|
||
return out, nil
|
||
}
|