Files
felhom-controller/controller/internal/quiesce/nightowed.go
T
admin 6d07ca2be4
gates / gates (push) Successful in 1m3s
R-899: a daytime press never cancels the night's whole-guest backup (operator ruling 2026-10-08, option A); press sends trigger=manual
Unreleased; ships with tomorrow's release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 07:34:03 +02:00

192 lines
6.6 KiB
Go

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)
}