Files
felhom-controller/controller/internal/quiesce/nightowed_test.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

193 lines
6.9 KiB
Go

package quiesce
import (
"bytes"
"log"
"path/filepath"
"strings"
"testing"
"time"
)
// R-899 (operator ruling 2026-10-08, option A): a daytime whole-guest backup never moves the night's backup.
// These assert the CONSEQUENCE — is tonight's local backup started? — not the ledger's mechanism.
func budapest(t *testing.T, y int, m time.Month, d, hh, mm int) time.Time {
t.Helper()
return time.Date(y, m, d, hh, mm, 0, 0, budapestLocation())
}
// r899Loop is a tiered loop with the window gate on (W = 02:30 → gate [04:30, 08:30)) and a settable clock.
func r899Loop(t *testing.T, be *tierBackend, st *fakeStacks, markerPath string, now *time.Time, logs *bytes.Buffer) *Loop {
t.Helper()
l := New(Options{
Backend: be, Stacks: st, MarkerPath: markerPath,
StatusPoll: time.Millisecond, MaxQuiesce: 30 * time.Second,
Logger: log.New(logs, "", 0),
})
l.windowStartFn = func() string { return "02:30" }
l.now = func() time.Time { return *now }
return l
}
func press(t *testing.T, l *Loop) {
t.Helper()
if err := l.TriggerNow(); err != nil {
t.Fatalf("TriggerNow: %v", err)
}
l.mu.Lock()
l.mu.Unlock() //nolint:staticcheck // wait for the async cycle
}
// The 2026-10-07 case, replayed: last night's backup at 04:35, a press at 08:49, and the agent then answers
// „not due" at 04:35 the next night (its newest archive is the press, 19.7 h old). Tonight must still back up.
// Before R-899 the cycle started nothing and the night had no OS leg and no kernel step.
func TestR899_DaytimePressDoesNotCancelTheNight(t *testing.T) {
st := &fakeStacks{running: []string{"opengist"}}
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
var logs bytes.Buffer
now := budapest(t, 2026, 10, 7, 4, 35)
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
be.setDue("local", true)
if err := l.runOnce(t.Context()); err != nil {
t.Fatalf("night 1: %v", err)
}
be.setDue("local", false)
now = budapest(t, 2026, 10, 7, 8, 49)
press(t, l)
now = budapest(t, 2026, 10, 8, 4, 35) // agent: local NOT due (the press is 19.7 h old)
if err := l.runOnce(t.Context()); err != nil {
t.Fatalf("night 2: %v", err)
}
got := be.startedTargets()
if len(got) != 3 || got[2] != "local" {
t.Fatalf("night 2 must take its own local backup despite the morning press; started=%v\nlogs:\n%s", got, logs.String())
}
if !strings.Contains(logs.String(), "R-899") {
t.Fatalf("the forced night must say why in the log; logs:\n%s", logs.String())
}
// The press was marked as a press; the two scheduled runs were not.
if m := be.manualStarts; len(m) != 3 || m[0] || !m[1] || m[2] {
t.Fatalf("press mark per start = %v, want [false true false]", m)
}
// And once tonight's backup ran, the debt is paid: a later poll in the same window starts nothing.
now = budapest(t, 2026, 10, 8, 4, 45)
if err := l.runOnce(t.Context()); err != nil {
t.Fatalf("night 2, later poll: %v", err)
}
if got := be.startedTargets(); len(got) != 3 {
t.Fatalf("tonight's backup already ran — no second one; started=%v", got)
}
}
// Outside the window the owed night waits for the window (the press's own age never fires the valve).
func TestR899_OwedNightWaitsForTheWindow(t *testing.T) {
st := &fakeStacks{running: []string{"opengist"}}
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true}}
var logs bytes.Buffer
now := budapest(t, 2026, 10, 7, 8, 49)
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
press(t, l)
now = budapest(t, 2026, 10, 7, 15, 0)
if err := l.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
if got := be.startedTargets(); len(got) != 1 {
t.Fatalf("outside the window nothing may start; started=%v", got)
}
now = budapest(t, 2026, 10, 8, 4, 31)
if err := l.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
if got := be.startedTargets(); len(got) != 2 || got[1] != "local" {
t.Fatalf("inside the window the owed night runs; started=%v", got)
}
}
// A press INSIDE the window after tonight's backup forces nothing more tonight.
func TestR899_PressAfterTonightsBackupForcesNothing(t *testing.T) {
st := &fakeStacks{running: []string{"opengist"}}
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true}}
var logs bytes.Buffer
now := budapest(t, 2026, 10, 8, 4, 35)
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
be.setDue("local", true)
if err := l.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
be.setDue("local", false)
now = budapest(t, 2026, 10, 8, 5, 0)
press(t, l)
now = budapest(t, 2026, 10, 8, 5, 10)
if err := l.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
if got := be.startedTargets(); len(got) != 2 {
t.Fatalf("tonight's scheduled backup already ran before the press — no third backup; started=%v", got)
}
}
// Without a press, an agent „not due" stands (no extra backups for a box nobody pressed).
func TestR899_NoPressNoExtraBackup(t *testing.T) {
st := &fakeStacks{running: []string{"opengist"}}
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true}}
var logs bytes.Buffer
now := budapest(t, 2026, 10, 8, 4, 35)
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
if err := l.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
if got := be.startedTargets(); len(got) != 0 {
t.Fatalf("no press, agent not due → nothing; started=%v", got)
}
}
// The press is remembered across a controller restart (the ledger is on disk beside the marker).
func TestR899_PressSurvivesARestart(t *testing.T) {
st := &fakeStacks{running: []string{"opengist"}}
be := newTierBackend()
be.tiers = []BackupTier{{Target: "local", Primary: true}}
var logs bytes.Buffer
marker := filepath.Join(t.TempDir(), "quiesce-state.json")
now := budapest(t, 2026, 10, 7, 8, 49)
press(t, r899Loop(t, be, st, marker, &now, &logs))
now = budapest(t, 2026, 10, 8, 4, 35)
l2 := r899Loop(t, be, st, marker, &now, &logs) // a new process
if err := l2.runOnce(t.Context()); err != nil {
t.Fatal(err)
}
if got := be.startedTargets(); len(got) != 2 || got[1] != "local" {
t.Fatalf("after a restart the press still does not count for tonight; started=%v", got)
}
}
// The gate-open instant, across midnight and a DST change.
func TestR899_LastGateOpen(t *testing.T) {
cases := []struct {
now time.Time
win string
want time.Time
}{
{budapest(t, 2026, 10, 8, 4, 35), "02:30", budapest(t, 2026, 10, 8, 4, 30)},
{budapest(t, 2026, 10, 8, 4, 29), "02:30", budapest(t, 2026, 10, 7, 4, 30)},
{budapest(t, 2026, 10, 8, 1, 0), "23:30", budapest(t, 2026, 10, 8, 1, 30).AddDate(0, 0, -1)},
{budapest(t, 2026, 10, 25, 5, 0), "02:30", budapest(t, 2026, 10, 25, 4, 30)}, // DST ends that night
}
for _, c := range cases {
got, ok := lastGateOpen(c.now, c.win)
if !ok || !got.Equal(c.want) {
t.Errorf("lastGateOpen(%s, %s) = %s, want %s", c.now, c.win, got, c.want)
}
}
}