package quiesce import ( "context" "encoding/json" "os" "path/filepath" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow" ) // R-899 (operator ruling 2026-10-08, option A): a daytime whole-guest backup never moves the night's backup. // Every night takes its own. // // The agent answers /backup/due from the newest archive on the tier's storage, and a household's „Mentés most" // press makes an archive like any other. So a press at 08:49 made the 24 h tier due again at 08:49 the next day — // after the gate window [W+2h, W+6h) had closed — and that night had no whole-guest backup, no OS leg and no // kernel step (measured on demo-hp, 2026-10-07 → 08, `audits/kernel-night-2026-10-07/readback/`). // // The rule, kept here and nowhere else: a press keeps its own record (it is a real copy, and the agent counts it // for everything else), but it does not count for „has tonight's backup run". A tier is OWED tonight when // // a press succeeded on it after its last SCHEDULED success, and // that last scheduled success is older than the opening of the current gate window (W+2h). // // An owed tier is due on the scheduled path even when the agent says it is not. The window gate still decides // WHEN (its age is the press's, so the safety valve never fires for it): outside the window it waits, inside it // runs. A scheduled success inside tonight's window ends the debt, so a press inside the window after tonight's // backup forces nothing. A failed scheduled run does not end it (the breaker still spaces the retries). // // Without a window function (the pre-v0.168.0 shape) nothing is owed: there is no night to protect. // // The ledger is durable (beside the quiesce marker) so a controller restart between the press and the night // does not forget the press. It is an attempt-free record of SUCCESSES only, and it is read only to decide // due-ness — never as evidence that a backup exists (the agent's storage answers that). // // Pinned by TestR899_* (nightowed_test.go). // wholeGuestLedger is the per-tier record of the last successful press and the last successful scheduled run. type wholeGuestLedger struct { Tiers map[string]ledgerTier `json:"tiers"` } type ledgerTier struct { PressOK time.Time `json:"press_ok,omitempty"` ScheduledOK time.Time `json:"scheduled_ok,omitempty"` } // manualCtxKey marks the context of a household press, so the agent adapter can tell the agent (R-899: the agent // runs the night's OS leg only after a scheduled backup). type manualCtxKey struct{} // WithManualTrigger marks ctx as a household press. func WithManualTrigger(ctx context.Context) context.Context { return context.WithValue(ctx, manualCtxKey{}, true) } // IsManualTrigger reports whether ctx belongs to a household press. func IsManualTrigger(ctx context.Context) bool { v, _ := ctx.Value(manualCtxKey{}).(bool) return v } func (l *Loop) ledgerPath() string { if l.markerPath == "" { return "" } return filepath.Join(filepath.Dir(l.markerPath), "whole-guest-ledger.json") } // loadLedger reads the ledger (in memory when there is no marker path). A missing or unreadable file is an // empty ledger: nothing owed, the agent's own answer stands — the pre-R-899 behaviour, never a skipped backup. func (l *Loop) loadLedger() wholeGuestLedger { l.ledgerMu.Lock() defer l.ledgerMu.Unlock() return l.loadLedgerLocked() } func (l *Loop) loadLedgerLocked() wholeGuestLedger { led := wholeGuestLedger{Tiers: map[string]ledgerTier{}} p := l.ledgerPath() if p == "" { for k, v := range l.memLedger { led.Tiers[k] = v } return led } data, err := os.ReadFile(p) if err != nil { if !os.IsNotExist(err) { l.logger.Printf("[WARN] [quiesce] whole-guest ledger unreadable (%v) — no night is owed by a press this poll (R-899)", err) } return led } if err := json.Unmarshal(data, &led); err != nil { l.logger.Printf("[WARN] [quiesce] whole-guest ledger corrupt (%v) — starting a new one (R-899)", err) return wholeGuestLedger{Tiers: map[string]ledgerTier{}} } if led.Tiers == nil { led.Tiers = map[string]ledgerTier{} } return led } // recordWholeGuestSuccess notes a successful backup on a tier, as a press or as a scheduled run. func (l *Loop) recordWholeGuestSuccess(target string, manual bool) { l.ledgerMu.Lock() defer l.ledgerMu.Unlock() led := l.loadLedgerLocked() t := led.Tiers[target] if manual { t.PressOK = l.now() } else { t.ScheduledOK = l.now() } led.Tiers[target] = t p := l.ledgerPath() if p == "" { if l.memLedger == nil { l.memLedger = map[string]ledgerTier{} } l.memLedger[target] = t return } data, err := json.MarshalIndent(led, "", " ") if err == nil { tmp := p + ".tmp" if err = os.WriteFile(tmp, data, 0o600); err == nil { err = os.Rename(tmp, p) } } if err != nil { l.logger.Printf("[WARN] [quiesce] could not save the whole-guest ledger (%v) — a press may still move the next night (R-899)", err) } } // pressOwesNight reports whether a press made this tier's agent answer „not due" while tonight's scheduled // backup has not run (the rule at the top of this file). func (l *Loop) pressOwesNight(led wholeGuestLedger, target string) bool { if l.windowStartFn == nil { return false } t, ok := led.Tiers[target] if !ok || t.PressOK.IsZero() || !t.PressOK.After(t.ScheduledOK) { return false } open, ok := lastGateOpen(l.now(), l.windowStartFn()) if !ok { return false } return t.ScheduledOK.Before(open) } // lastGateOpen returns the most recent opening of the gate window (W+2h, Budapest wall clock) at or before now. func lastGateOpen(now time.Time, windowStart string) (time.Time, bool) { startMin, err := backupwindow.ParseHHMM(windowStart) if err != nil { return time.Time{}, false } openMin := mod1440(startMin + gateOpenOffsetMin) loc := budapestLocation() n := now.In(loc) open := time.Date(n.Year(), n.Month(), n.Day(), openMin/60, openMin%60, 0, 0, loc) if open.After(n) { open = open.AddDate(0, 0, -1) } return open, true } // withoutOwed drops the R-899 owed tiers: they never license the window gate's safety valve. func withoutOwed(tiers []dueTier) []dueTier { out := make([]dueTier, 0, len(tiers)) for _, t := range tiers { if !t.owed { out = append(out, t) } } return out } // gateAge is the age the window gate judges: the oldest of the tiers that are due by the agent. When every due tier // is owed by a press, it is zero — „just backed up" — so outside the window the gate waits and inside it runs. func gateAge(tiers []dueTier) *int64 { agentDue := withoutOwed(tiers) if len(agentDue) == 0 { zero := int64(0) return &zero } return oldestAge(agentDue) }