82c67e32e1
ONE setting (window start W) drives every nightly leg at fixed, never-stored offsets: DB dump at W, tier-2 at W+60m, off-box at W+105m (wrap-safe). Precedence settings > controller.yaml db_dump_schedule > 02:30. - scheduler.UpdateDaily: retime a daily job at runtime (no restart) via a per-job buffered resched chan + a select case in runDailyJob. - new pure package internal/backupwindow (LegTimes/GateWindow/EffectiveWindow). - quiesce disk-tier window gate: scheduled cycles run only inside [W+2h,W+6h) with a safety valve (age>cadence+24h runs regardless); manual TriggerNow never gated. Backend.Due now also returns the backup age (from the agent's own /backup/due). - backup page: Mentési időablak card (time input + derived leg/gate rows); POST /backups/window validates -> saves -> UpdateDaily x3 -> flash. Tests: 5 groups, all red-proofed. Agent/cadence//backup/due untouched.
130 lines
5.0 KiB
Go
130 lines
5.0 KiB
Go
package quiesce
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func i64(v int64) *int64 { return &v }
|
|
|
|
// atBudapest builds a time whose Budapest wall-clock hour/minute are h:m (the predicate reads only
|
|
// those; the caller in the loop passes now.In(Budapest)).
|
|
func atBudapest(h, m int) time.Time {
|
|
return time.Date(2026, 7, 24, h, m, 0, 0, budapestLocation())
|
|
}
|
|
|
|
const cadence24 = 24 * time.Hour
|
|
|
|
// Group D — scheduledRunAllowed truth table: inside gate / outside / outside+valve / wrap / nil age.
|
|
// Window 02:30 → gate [04:30, 08:30). Valve threshold = cadence+24h = 48h.
|
|
// Red-proof: invert the valve comparison (`<` instead of `>`) and the starving-box case (age 49h,
|
|
// outside window) flips to false.
|
|
func TestScheduledRunAllowed(t *testing.T) {
|
|
h := func(hours int64) *int64 { return i64(hours * 3600) }
|
|
cases := []struct {
|
|
name string
|
|
now time.Time
|
|
window string
|
|
age *int64
|
|
want bool
|
|
}{
|
|
{"inside gate, recent backup", atBudapest(5, 0), "02:30", h(20), true},
|
|
{"gate open boundary (inclusive)", atBudapest(4, 30), "02:30", h(20), true},
|
|
{"gate close boundary (exclusive)", atBudapest(8, 30), "02:30", h(20), false},
|
|
{"outside gate, no valve", atBudapest(12, 0), "02:30", h(20), false},
|
|
{"outside gate, valve (age > 48h)", atBudapest(12, 0), "02:30", h(49), true},
|
|
{"outside gate, nil age (no backup yet)", atBudapest(12, 0), "02:30", nil, true},
|
|
{"wrap: inside gate across midnight", atBudapest(2, 0), "23:00", h(20), true},
|
|
{"wrap: outside gate across midnight", atBudapest(12, 0), "23:00", h(20), false},
|
|
{"unparseable window fails open", atBudapest(12, 0), "nonsense", h(20), true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := scheduledRunAllowed(c.now, c.window, c.age, cadence24); got != c.want {
|
|
t.Errorf("%s: scheduledRunAllowed(%s, %q, age, cadence) = %v, want %v",
|
|
c.name, c.now.Format("15:04"), c.window, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// windowLoop builds a Loop with the gate wired and now/window overridden for deterministic tests.
|
|
func windowLoop(t *testing.T, be Backend, st Stacks, window string, now time.Time) *Loop {
|
|
t.Helper()
|
|
l := testLoop(t, be, st)
|
|
l.windowStartFn = func() string { return window }
|
|
l.cadence = cadence24
|
|
l.now = func() time.Time { return now }
|
|
return l
|
|
}
|
|
|
|
// Group D (integration) — a DUE scheduled cycle outside the window with a recent backup is deferred:
|
|
// no StartBackup, no stacks stopped.
|
|
func TestRunOnce_GateDefersOutsideWindow(t *testing.T) {
|
|
be := &fakeBackend{due: true, dueAge: i64(20 * 3600)}
|
|
st := &fakeStacks{running: []string{"nextcloud"}}
|
|
l := windowLoop(t, be, st, "02:30", atBudapest(12, 0)) // gate [04:30,08:30), 12:00 is outside
|
|
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatalf("runOnce: %v", err)
|
|
}
|
|
if be.startCalls != 0 {
|
|
t.Errorf("gate should have deferred, but StartBackup was called %d time(s)", be.startCalls)
|
|
}
|
|
if len(st.stoppedNames()) != 0 {
|
|
t.Errorf("gate should have deferred, but stacks were stopped: %v", st.stoppedNames())
|
|
}
|
|
}
|
|
|
|
// Group D (integration) — inside the window the cycle runs normally.
|
|
func TestRunOnce_GateRunsInsideWindow(t *testing.T) {
|
|
be := &fakeBackend{due: true, dueAge: i64(20 * 3600), phases: []string{"done"}}
|
|
st := &fakeStacks{running: []string{"nextcloud"}}
|
|
l := windowLoop(t, be, st, "02:30", atBudapest(5, 0)) // 05:00 is inside [04:30,08:30)
|
|
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatalf("runOnce: %v", err)
|
|
}
|
|
if be.startCalls != 1 {
|
|
t.Errorf("inside the window the cycle should run; StartBackup calls = %d", be.startCalls)
|
|
}
|
|
}
|
|
|
|
// Group D (integration) — outside the window but the safety valve holds (box was off during its
|
|
// window; last backup older than cadence+24h): the cycle runs regardless of the window.
|
|
func TestRunOnce_ValveRunsOutsideWindow(t *testing.T) {
|
|
be := &fakeBackend{due: true, dueAge: i64(49 * 3600), phases: []string{"done"}}
|
|
st := &fakeStacks{running: []string{"nextcloud"}}
|
|
l := windowLoop(t, be, st, "02:30", atBudapest(12, 0)) // outside, but age 49h > 48h valve
|
|
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatalf("runOnce: %v", err)
|
|
}
|
|
if be.startCalls != 1 {
|
|
t.Errorf("safety valve should have run the cycle; StartBackup calls = %d", be.startCalls)
|
|
}
|
|
}
|
|
|
|
// Group D (integration) — the MANUAL trigger path is never gated: TriggerNow runs quiesceAndPoll
|
|
// directly (bypassing runOnce), so an outside-window manual backup still fires.
|
|
func TestTriggerNow_NotGated(t *testing.T) {
|
|
be := &fakeBackend{due: false, phases: []string{"done"}} // not due — only the manual path can run it
|
|
st := &fakeStacks{running: []string{"nextcloud"}}
|
|
l := windowLoop(t, be, st, "02:30", atBudapest(12, 0)) // outside the window
|
|
|
|
if err := l.TriggerNow(); err != nil {
|
|
t.Fatalf("TriggerNow: %v", err)
|
|
}
|
|
// TriggerNow runs asynchronously — wait for the backup to be started.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
be.mu.Lock()
|
|
n := be.startCalls
|
|
be.mu.Unlock()
|
|
if n == 1 {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatal("manual TriggerNow did not start a backup — it must never be gated by the window")
|
|
}
|