6d07ca2be4
gates / gates (push) Successful in 1m3s
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
192 lines
6.6 KiB
Go
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)
|
|
}
|