046df303b6
gates / gates (push) Successful in 7s
Found by checking v0.91.0 against the live box, not by review. demo-felhom's two retained PBS snapshots sit 8h54m apart (one is a healing artefact), so the mean-gap estimator reads a WEEKLY tier as nine-hourly: x4 = 36h, the 7-day floor lifts it to 168h, and a weekly tier proved weekly reaches ~8.25d of proof age. The false alarm this task exists to prevent would have returned within a week, on the box it had just shipped to. restoreProvenWindow now takes max(observed, declared). A gap SHORTER than the declared rhythm is routine and means nothing (a retry, a manual run, a heal, a catch-up); a gap LONGER than it is real information. Cost stated: a tier running faster than its declared rhythm gets a slower stale signal — the right direction for a signal that means 'unverified', since 'broken now' is a different event.
478 lines
20 KiB
Go
478 lines
20 KiB
Go
package monitor
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"sync"
|
||
"time"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||
)
|
||
|
||
// R-85 Part 2 — the restore-test result becomes a SIGNAL.
|
||
//
|
||
// Until now a failed restore-test was a `[WARN]` log line in the ingest handler and nothing else:
|
||
// no event, no notification, no staleness gauge. That was true for the LOCAL tier that was already
|
||
// being tested — so the loudest DR signal this system produces was, in practice, inaudible.
|
||
//
|
||
// TWO SIGNALS, DELIBERATELY NOT MERGED. They mean different things and warrant different urgency:
|
||
//
|
||
// restore_test_failed — a run completed and did NOT pass. Something is broken NOW.
|
||
// restore_test_stale — a tier has not been PROVEN within its expected interval. Nothing has
|
||
// necessarily broken; we simply no longer know whether it works.
|
||
//
|
||
// Merging them would collapse "your DR is broken" into "your DR is unverified", and the second is
|
||
// the one that quietly becomes the first.
|
||
//
|
||
// ── THE INVARIANT, inherited from R-81 ────────────────────────────────────────────────────────
|
||
//
|
||
// Absence of a signal is UNKNOWN, and becomes a fault only once it has outlived an ANCHORED window.
|
||
// This monitor family has made the opposite mistake three times (hub v0.12.0, v0.73.0, R-81); this
|
||
// is a NEW monitor written straight after the third, so it copies R-81's verdict structure rather
|
||
// than re-deriving it. A tier never proven on a newborn box is UNKNOWN, never FAILED.
|
||
|
||
// ── HOW LONG MAY A TIER GO UNPROVEN? (R-86 Part 2) ───────────────────────────────────────────
|
||
//
|
||
// This was one flat constant, 7 days, and its comment derived that number like this:
|
||
//
|
||
// "the restore-test cadence is 24h and rotation is oldest-first across two tiers, so each tier is
|
||
// proven roughly every 2 days. 7 days therefore tolerates ~3 consecutive missed opportunities."
|
||
//
|
||
// **That premise is exactly what R-86 removed.** The agent no longer tests on an interval at all: a
|
||
// tier is tested once per ARCHIVE GENERATION — when it holds a settled archive that has not been
|
||
// proven. A tier backed up weekly is therefore proved weekly, by design and in perfect health, and
|
||
// against a flat 7-day window it would sit on the line and alarm every night about a system that is
|
||
// working. Shipping the agent's half alone would have converted the improvement into a false alarm,
|
||
// which is why the two ship together.
|
||
//
|
||
// The window is now derived from **the tier's own backup rhythm**, which the hub can observe from
|
||
// the reports it already receives, and it keeps everything the constant had earned:
|
||
//
|
||
// - absence is UNKNOWN until an anchored window has passed (R-81's structure, untouched);
|
||
// - the signal stays edge-triggered;
|
||
// - it never exceeds the offsite retention, so a tier is never called stale against an archive
|
||
// that is about to be pruned;
|
||
// - and it is never TIGHTER than the 7 days that were already tolerated.
|
||
const (
|
||
// restoreProvenGenerations is how many archive generations may pass unproven before alarming.
|
||
// 4 = the settle lag's own generation plus ~3 missed opportunities — deliberately the same
|
||
// tolerance the flat constant expressed, so the change is to the RHYTHM, not to the patience.
|
||
restoreProvenGenerations = 4
|
||
|
||
// restoreProvenWindowFloor is the shortest window that may be applied to any tier. It is the
|
||
// old constant, kept as a FLOOR rather than deleted: a daily tier computes 4 days from its own
|
||
// rhythm, and tightening a live threshold is not what this task is for. A deferral behind a
|
||
// long backup is normal, not a fault.
|
||
restoreProvenWindowFloor = 7 * 24 * time.Hour
|
||
|
||
// restoreProvenWindowCap keeps the window strictly inside the 2-week offsite retention
|
||
// (operator ruling 2026-07-26) with two days to spare. Beyond it the hub would be judging a
|
||
// tier against an archive PBS has already pruned — an alarm nobody can act on, and the bound
|
||
// the old constant respected in its own way.
|
||
restoreProvenWindowCap = 12 * 24 * time.Hour
|
||
|
||
// restoreWindowRead is how far back the hub reads host-reports for this check: far enough to
|
||
// find proof anywhere inside the widest window, and to see at least two archive generations of
|
||
// a WEEKLY tier so its rhythm is observable at all.
|
||
restoreWindowRead = 2 * restoreProvenWindowFloor
|
||
)
|
||
|
||
// declaredArchiveInterval is the rhythm the hub ALREADY attributes to a tier — the same thresholds
|
||
// the backup-freshness checker judges it against (deadline.go / deadline_tiers.go). It is the
|
||
// fallback when a box's history is too short to observe a rhythm, and it is the right fallback
|
||
// precisely because it is not a second opinion: if these two checkers disagreed about how often a
|
||
// tier is expected to receive an archive, one of them would be alarming on the other's model.
|
||
//
|
||
// It is stated per RESTORE tier name ("local"/"pbs" — what the agent reports as source_tier), which
|
||
// is the same split the backup tiers use under different names ("host"/"offsite").
|
||
func declaredArchiveInterval(tier string) time.Duration {
|
||
if tier == "pbs" {
|
||
return offsiteBackupStaleAfter // 8 days: the weekly cadence plus a day of headroom
|
||
}
|
||
return backupStaleAfter // 26 hours: the daily cadence plus headroom
|
||
}
|
||
|
||
// restoreProvenWindow is how long THIS tier may go unproven, given its observed archive interval.
|
||
//
|
||
// observedOK=false means the box's retained history did not contain two archive generations for
|
||
// this tier, so the declared rhythm is used. That fallback matters most for exactly the tier this
|
||
// task is about: a fresh box with a weekly offsite tier has one snapshot and no observable
|
||
// interval, and falling back to the FLOOR there would recreate the false alarm.
|
||
//
|
||
// OBSERVATION MAY ONLY WIDEN, NEVER TIGHTEN — and this is not caution, it is a live measurement.
|
||
// On demo-felhom (2026-08-03) the offsite tier's two retained snapshots are `2026-07-27T19:55:41Z`
|
||
// and `2026-07-28T04:49:43Z`: **8 h 54 m apart**, because one is a healing artefact and the other a
|
||
// real weekly run. A mean-gap estimate therefore reads a WEEKLY tier as nine-hourly, ×4 gives 36 h,
|
||
// the floor lifts it to 7 days — and a weekly tier proved weekly reaches ~8.25 days of proof age, so
|
||
// the false alarm this whole task exists to prevent would have returned within a week, on the very
|
||
// box it shipped to.
|
||
//
|
||
// The asymmetry is right on its own terms too. A gap SHORTER than the declared rhythm is routine and
|
||
// means nothing — a retry, a manual run, a heal, a catch-up after an outage. A gap LONGER than the
|
||
// declared rhythm is real information: this tier genuinely receives archives less often than the
|
||
// model says, and its window must widen or it alarms. So observation refines the rhythm upward and
|
||
// is ignored downward. The cost is stated plainly: a tier that truly runs FASTER than its declared
|
||
// rhythm gets a wider window than it strictly needs, i.e. a slower stale signal. That is the right
|
||
// direction for a signal whose message is "unverified" — "broken NOW" is `restore_test_failed`, and
|
||
// that one is immediate and unaffected.
|
||
func restoreProvenWindow(tier string, observed time.Duration, observedOK bool) time.Duration {
|
||
interval := declaredArchiveInterval(tier)
|
||
if observedOK && observed > interval {
|
||
interval = observed
|
||
}
|
||
w := time.Duration(restoreProvenGenerations) * interval
|
||
if w < restoreProvenWindowFloor {
|
||
w = restoreProvenWindowFloor
|
||
}
|
||
if w > restoreProvenWindowCap {
|
||
w = restoreProvenWindowCap
|
||
}
|
||
return w
|
||
}
|
||
|
||
// Event types. Operator-tier only — see the dispatcher note in RestoreTestChecker.
|
||
const (
|
||
EventRestoreTestFailed = "restore_test_failed"
|
||
EventRestoreTestStale = "restore_test_stale"
|
||
)
|
||
|
||
// hostReportRestoreTests is the slice of a host-report this checker reads.
|
||
type hostReportRestoreTests struct {
|
||
RestoreTests []struct {
|
||
SourceArchive string `json:"source_archive"`
|
||
SourceTier string `json:"source_tier"`
|
||
Pass bool `json:"pass"`
|
||
Error string `json:"error"`
|
||
TestedAt string `json:"tested_at"`
|
||
} `json:"restore_tests"`
|
||
StorageTargets []struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Content string `json:"content"`
|
||
} `json:"storage_targets"`
|
||
}
|
||
|
||
// RestoreTestChecker watches restore-test outcomes per customer.
|
||
type RestoreTestChecker struct {
|
||
store *store.Store
|
||
logger *log.Logger
|
||
onEvent EventNotifyFunc
|
||
now func() time.Time
|
||
|
||
mu sync.Mutex
|
||
// failStates is edge-trigger state: customerID|archive → already reported. A failing tier is
|
||
// re-reported only when the FAILING RUN changes, so a permanently broken tier does not emit
|
||
// every 60s sweep — the flapping-spam rule every checker here follows.
|
||
failStates map[string]bool
|
||
// staleStates is customerID|tier → "ok"|"stale", so the stale signal is edge-triggered too.
|
||
staleStates map[string]string
|
||
}
|
||
|
||
// NewRestoreTestChecker builds the checker. onEvent may be nil.
|
||
func NewRestoreTestChecker(s *store.Store, onEvent EventNotifyFunc, logger *log.Logger) *RestoreTestChecker {
|
||
return &RestoreTestChecker{
|
||
store: s, logger: logger, onEvent: onEvent,
|
||
now: func() time.Time { return time.Now().UTC() },
|
||
failStates: map[string]bool{},
|
||
staleStates: map[string]string{},
|
||
}
|
||
}
|
||
|
||
// Check sweeps every active customer. Safe to call on the shared monitor ticker.
|
||
func (c *RestoreTestChecker) Check() {
|
||
ids, err := c.store.GetActiveCustomerIDs()
|
||
if err != nil {
|
||
c.logger.Printf("[WARN] restore-test check: failed to list customers: %v", err)
|
||
return
|
||
}
|
||
now := c.now()
|
||
for _, id := range ids {
|
||
latest, rerr := c.store.GetLatestHostReportJSON(id)
|
||
if rerr != nil || latest == "" {
|
||
continue // no agent host-report → nothing to judge (the R-81 legacy-shape branch)
|
||
}
|
||
c.checkFailure(id, latest)
|
||
c.checkStaleness(id, latest, now)
|
||
}
|
||
}
|
||
|
||
// checkFailure raises restore_test_failed when the latest report carries a FAILED run.
|
||
func (c *RestoreTestChecker) checkFailure(customerID, reportJSON string) {
|
||
var hr hostReportRestoreTests
|
||
if json.Unmarshal([]byte(reportJSON), &hr) != nil {
|
||
return
|
||
}
|
||
for _, rt := range hr.RestoreTests {
|
||
if rt.Pass {
|
||
// A pass CLEARS the edge state for that archive so a later failure re-reports.
|
||
c.mu.Lock()
|
||
delete(c.failStates, customerID+"|"+rt.SourceArchive)
|
||
c.mu.Unlock()
|
||
continue
|
||
}
|
||
key := customerID + "|" + rt.SourceArchive
|
||
c.mu.Lock()
|
||
already := c.failStates[key]
|
||
c.failStates[key] = true
|
||
c.mu.Unlock()
|
||
if already {
|
||
continue
|
||
}
|
||
tier := rt.SourceTier
|
||
if tier == "" {
|
||
tier = "unknown"
|
||
}
|
||
msg := fmt.Sprintf("Restore-test FAILED on the %s tier: archive %s could not be restored+booted (%s)",
|
||
tier, rt.SourceArchive, rt.Error)
|
||
c.emit(customerID, EventRestoreTestFailed, "error", msg)
|
||
}
|
||
}
|
||
|
||
// checkStaleness raises restore_test_stale for a tier not PROVEN within restoreProvenStaleAfter.
|
||
//
|
||
// "Proven" means a PASSING run for that tier, found across the hub's retained host-report window —
|
||
// the R-81 mechanism. The window is load-bearing here: the agent reports only its LATEST
|
||
// restore-test and its store is in-memory, so the latest report alone cannot answer "when was the
|
||
// OTHER tier last proven?". The hub's own history can.
|
||
func (c *RestoreTestChecker) checkStaleness(customerID, latestJSON string, now time.Time) {
|
||
var latest hostReportRestoreTests
|
||
if json.Unmarshal([]byte(latestJSON), &latest) != nil {
|
||
return
|
||
}
|
||
tiers := expectedRestoreTiers(latest)
|
||
if len(tiers) == 0 {
|
||
return
|
||
}
|
||
|
||
rows, err := c.store.GetHostReportsSince(customerID, now.Add(-restoreWindowRead))
|
||
if err != nil {
|
||
c.logger.Printf("[WARN] restore-test check: window read failed for %s: %v", customerID, err)
|
||
return
|
||
}
|
||
proven := lastProvenPerTier(rows)
|
||
intervals := observedArchiveIntervals(rows)
|
||
|
||
first, ferr := c.store.GetFirstHostReportAt(customerID)
|
||
if ferr != nil {
|
||
c.logger.Printf("[WARN] restore-test check: first-contact read failed for %s: %v", customerID, ferr)
|
||
return
|
||
}
|
||
|
||
for _, tier := range tiers {
|
||
observed, observedOK := intervals[tier]
|
||
window := restoreProvenWindow(tier, observed, observedOK)
|
||
v := assessRestoreProven(tier, proven[tier], first, now, window)
|
||
key := customerID + "|" + tier
|
||
c.mu.Lock()
|
||
prev := c.staleStates[key]
|
||
state := "ok"
|
||
if v.verdict == verdictMissed {
|
||
state = "stale"
|
||
}
|
||
c.staleStates[key] = state
|
||
c.mu.Unlock()
|
||
|
||
switch {
|
||
case state == "stale" && prev != "stale":
|
||
c.emit(customerID, EventRestoreTestStale, "warning", v.reason)
|
||
case v.verdict == verdictUnknown && prev == "":
|
||
// Make the deferral VISIBLE exactly once, the v0.73.0 / R-81 precedent: a quiet check
|
||
// must never be indistinguishable from a check that did not run.
|
||
c.logger.Printf("[INFO] restore-test staleness: %s %s", customerID, v.reason)
|
||
}
|
||
}
|
||
}
|
||
|
||
// assessRestoreProven is the per-tier verdict. PURE (now and the window injected) so the policy is
|
||
// unit-tested — the property that made R-81 provable, kept deliberately.
|
||
//
|
||
// no proof, anchor NOT elapsed → UNKNOWN (newborn box; never an alarm)
|
||
// no proof, anchor elapsed → MISSED
|
||
// proof older than the window → MISSED
|
||
// otherwise → OK
|
||
//
|
||
// `window` is now the TIER'S OWN (R-86 Part 2) rather than one constant for every tier, and every
|
||
// reason string states the window it was judged against. That is R-100's corollary applied here:
|
||
// when a verdict changes what it counts from, the alarm text has to change with it, or an operator
|
||
// reads "limit 168h" under a tier that was actually judged at 288h and dismisses a true alarm.
|
||
func assessRestoreProven(tier string, provenAt, firstReportAt, now time.Time, window time.Duration) backupAssessment {
|
||
if provenAt.IsZero() {
|
||
if firstReportAt.IsZero() {
|
||
return backupAssessment{verdict: verdictMissed,
|
||
reason: fmt.Sprintf("%s tier: never restore-proven, and no first-contact anchor to defer against", tier)}
|
||
}
|
||
watched := now.Sub(firstReportAt)
|
||
if watched <= window {
|
||
return backupAssessment{verdict: verdictUnknown,
|
||
reason: fmt.Sprintf("%s tier: not restore-proven yet, but only watching for %s (grace %s since first contact %s) — newborn, not a fault",
|
||
tier, watched.Round(time.Hour), window, firstReportAt.Format(time.RFC3339))}
|
||
}
|
||
return backupAssessment{verdict: verdictMissed,
|
||
reason: fmt.Sprintf("%s tier: NEVER successfully restore-proven in %s of watching (limit %s, this tier's own backup rhythm) — the tier is unverified, not known-broken",
|
||
tier, watched.Round(time.Hour), window)}
|
||
}
|
||
if age := now.Sub(provenAt); age > window {
|
||
return backupAssessment{verdict: verdictMissed,
|
||
reason: fmt.Sprintf("%s tier: last successful restore-test was %s ago (limit %s, this tier's own backup rhythm) — the tier is unverified, not known-broken",
|
||
tier, age.Round(time.Hour), window)}
|
||
}
|
||
return backupAssessment{verdict: verdictOK}
|
||
}
|
||
|
||
// observedArchiveIntervals estimates how often each RESTORE tier actually receives an archive, from
|
||
// the host-reports the hub already holds. Keyed by restore-tier name ("local"/"pbs").
|
||
//
|
||
// Evidence is every distinct archive timestamp in the window: `pbs_snapshots[]` for the offsite
|
||
// tier (PBS enumerates its whole retention in each report, so one report usually settles the
|
||
// question) and successful `backups[]` records attributed by TARGET TYPE for both tiers — the
|
||
// slice-A.4 rule, because a PBS-targeted vzdump appears in BOTH arrays and classifying by array
|
||
// membership would attribute an offsite archive to the host tier.
|
||
//
|
||
// The estimate is the MEAN gap: (newest − oldest) / (generations − 1). It needs two generations;
|
||
// with fewer, ok=false and the caller falls back to the declared rhythm. It is deliberately crude,
|
||
// and can afford to be: restoreProvenWindow clamps the result between a 7-day floor and a 12-day
|
||
// cap, so the only discrimination this has to get right is "roughly daily" versus "several days or
|
||
// slower" — which is exactly the distinction that turns a healthy weekly tier into a false alarm.
|
||
func observedArchiveIntervals(rows []store.HostReportRow) map[string]time.Duration {
|
||
seen := map[string]map[int64]struct{}{ // tier → set of archive unix times
|
||
"local": {},
|
||
"pbs": {},
|
||
}
|
||
add := func(tier string, t time.Time) {
|
||
if t.IsZero() {
|
||
return
|
||
}
|
||
seen[tier][t.UTC().Unix()] = struct{}{}
|
||
}
|
||
|
||
for _, r := range rows {
|
||
var hr hostReportBackups
|
||
if json.Unmarshal([]byte(r.ReportJSON), &hr) != nil {
|
||
continue // one malformed retained report must not blind the scan
|
||
}
|
||
pbs := pbsTargetSet(hr)
|
||
for _, ps := range hr.PBSSnapshots {
|
||
if t, ok := parseBackupTime(ps.BackupTime); ok {
|
||
add("pbs", t)
|
||
}
|
||
}
|
||
for _, b := range hr.Backups {
|
||
if !b.Success {
|
||
continue
|
||
}
|
||
t, ok := parseBackupTime(b.StartedAt)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if pbs[b.TargetID] {
|
||
add("pbs", t)
|
||
} else {
|
||
add("local", t)
|
||
}
|
||
}
|
||
}
|
||
|
||
out := map[string]time.Duration{}
|
||
for tier, set := range seen {
|
||
if len(set) < 2 {
|
||
continue // not observable — the caller uses the declared rhythm
|
||
}
|
||
var oldest, newest int64
|
||
first := true
|
||
for ts := range set {
|
||
if first || ts < oldest {
|
||
oldest = ts
|
||
}
|
||
if first || ts > newest {
|
||
newest = ts
|
||
}
|
||
first = false
|
||
}
|
||
span := time.Duration(newest-oldest) * time.Second
|
||
if span <= 0 {
|
||
continue
|
||
}
|
||
out[tier] = span / time.Duration(len(set)-1)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// expectedRestoreTiers names the tiers this box actually HAS, so a box without an offsite tier is
|
||
// never reported stale for one. Same gate as Slice C's `expected`, and for the same reason: without
|
||
// it every box lacking a tier would alarm once the anchor elapsed — absence-is-not-failure,
|
||
// re-introduced one level down.
|
||
func expectedRestoreTiers(hr hostReportRestoreTests) []string {
|
||
var out []string
|
||
seenHost, seenOffsite := false, false
|
||
for _, st := range hr.StorageTargets {
|
||
isPBS := st.Type == "pbs"
|
||
if isPBS && !seenOffsite {
|
||
out = append(out, "pbs")
|
||
seenOffsite = true
|
||
continue
|
||
}
|
||
if !isPBS && !seenHost && containsBackupContent(st.Content) {
|
||
out = append(out, "local")
|
||
seenHost = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func containsBackupContent(content string) bool {
|
||
for i := 0; i+6 <= len(content); i++ {
|
||
if content[i:i+6] == "backup" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// lastProvenPerTier walks the retained window for the newest PASSING run per tier.
|
||
func lastProvenPerTier(rows []store.HostReportRow) map[string]time.Time {
|
||
out := map[string]time.Time{}
|
||
for _, r := range rows {
|
||
var hr hostReportRestoreTests
|
||
if json.Unmarshal([]byte(r.ReportJSON), &hr) != nil {
|
||
continue // one malformed retained report must not blind the scan
|
||
}
|
||
for _, rt := range hr.RestoreTests {
|
||
if !rt.Pass || rt.SourceTier == "" {
|
||
continue
|
||
}
|
||
t, err := time.Parse(time.RFC3339, rt.TestedAt)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if cur, ok := out[rt.SourceTier]; !ok || t.After(cur) {
|
||
out[rt.SourceTier] = t.UTC()
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// emit saves the event and notifies. OPERATOR-TIER ONLY: neither type has a `customerMessages`
|
||
// entry, so the dispatcher cannot route it to a customer. That is deliberate — a customer can take
|
||
// no action on a failed restore-test, and „a visszaállítási teszt nem sikerült" would frighten
|
||
// without informing. A PERSISTENTLY unproven DR tier may eventually warrant a customer-visible
|
||
// statement, but that needs copy review, not a side effect of this task.
|
||
func (c *RestoreTestChecker) emit(customerID, eventType, severity, msg string) {
|
||
if _, err := c.store.SaveEvent(customerID, eventType, severity, msg, "{}", "hub"); err != nil {
|
||
c.logger.Printf("[WARN] restore-test check: failed to save %s for %s: %v", eventType, customerID, err)
|
||
return
|
||
}
|
||
c.logger.Printf("[%s] restore-test: %s %s", severityLabel(severity), customerID, msg)
|
||
if c.onEvent != nil {
|
||
c.onEvent(customerID, eventType, severity, msg, "{}", "hub")
|
||
}
|
||
}
|
||
|
||
func severityLabel(sev string) string {
|
||
if sev == "error" {
|
||
return "ERROR"
|
||
}
|
||
return "WARN"
|
||
}
|