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 }