v0.168.0: customer-configurable backup window (Mentési időablak)

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.
This commit is contained in:
2026-07-24 20:55:44 +02:00
parent e33c1aeabc
commit 82c67e32e1
19 changed files with 831 additions and 31 deletions
@@ -0,0 +1,83 @@
// Package backupwindow holds the pure time arithmetic for the customer-configurable backup window
// (v0.168.0). ONE setting — the window start W — drives every nightly leg at FIXED offsets so the
// legs can never be misordered, and never stores a derived time: the DB dump runs at W, the tier-2
// mirror at W+60m, the off-box copy at W+105m; the whole-guest (PBS/vzdump) cycle is gated to
// [W+2h, W+6h). Offsets are constants here, never persisted and never surfaced in the UI.
package backupwindow
import "fmt"
// DefaultWindow is the last-resort window when neither settings nor controller.yaml supplies one.
// It equals the historical hardcoded DB-dump time, so an un-configured box behaves exactly as before.
const DefaultWindow = "02:30"
// Fixed leg offsets from the window start W (minutes). NEVER stored, NEVER exposed in the UI —
// changing spacing/ordering is a code change here, not customer data.
const (
tier2OffsetMin = 60 // tier-2 mirror at W+60m
offboxOffsetMin = 105 // off-box copy at W+105m
gateStartMin = 120 // whole-guest gate opens at W+2h
gateEndMin = 360 // whole-guest gate closes (exclusive) at W+6h
)
// ParseHHMM parses "HH:MM" (24h) into minutes-since-midnight. It rejects anything but a valid
// hour:minute — the same contract as the scheduler's parseDailyTime, kept here so this package is
// dependency-free and reusable by the quiesce gate.
func ParseHHMM(s string) (int, error) {
var h, m int
n, err := fmt.Sscanf(s, "%d:%d", &h, &m)
if err != nil || n != 2 {
return 0, fmt.Errorf("expected HH:MM format, got %q", s)
}
if h < 0 || h > 23 || m < 0 || m > 59 {
return 0, fmt.Errorf("invalid time %q: hour must be 0-23, minute 0-59", s)
}
return h*60 + m, nil
}
// FmtHHMM renders minutes-since-midnight back to "HH:MM", wrapping across midnight (modulo 24h) so
// derived legs past 23:59 read correctly (e.g. 23:30 + 60m → 00:30).
func FmtHHMM(minutes int) string {
minutes = ((minutes % 1440) + 1440) % 1440
return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60)
}
// Valid reports whether s is a well-formed HH:MM window value (nil error = valid).
func Valid(s string) error {
_, err := ParseHHMM(s)
return err
}
// LegTimes returns the three derived nightly-leg times (db=W, tier2=W+60m, offbox=W+105m),
// wrap-safe across midnight. On an invalid start it returns three empty strings — callers pass a
// value already resolved through EffectiveWindow, which never yields an invalid string.
func LegTimes(start string) (db, tier2, offbox string) {
m, err := ParseHHMM(start)
if err != nil {
return "", "", ""
}
return FmtHHMM(m), FmtHHMM(m + tier2OffsetMin), FmtHHMM(m + offboxOffsetMin)
}
// GateWindow returns the whole-guest backup gate bounds [W+2h, W+6h) as HH:MM strings (for the UI
// "kb. <from><to> között" line and the gate-denial log). Empty strings on an invalid start.
func GateWindow(start string) (from, to string) {
m, err := ParseHHMM(start)
if err != nil {
return "", ""
}
return FmtHHMM(m + gateStartMin), FmtHHMM(m + gateEndMin)
}
// EffectiveWindow resolves the active window by precedence: a valid settings value wins over a valid
// controller.yaml value, which wins over DefaultWindow. An empty or corrupted value simply falls
// through — so a bad settings string degrades to the yaml default rather than breaking scheduling.
func EffectiveWindow(settingsVal, yamlVal string) string {
if Valid(settingsVal) == nil {
return settingsVal
}
if Valid(yamlVal) == nil {
return yamlVal
}
return DefaultWindow
}
@@ -0,0 +1,69 @@
package backupwindow
import "testing"
// Group A — LegTimes derives the three nightly legs at fixed offsets, wrap-safe across midnight.
// Red-proof: drop the modulo in FmtHHMM → the 23:30 case yields "24:30"/"25:15" and fails.
func TestLegTimes(t *testing.T) {
cases := []struct{ start, db, tier2, offbox string }{
{"02:30", "02:30", "03:30", "04:15"}, // the default window
{"23:30", "23:30", "00:30", "01:15"}, // wraps past midnight
{"22:00", "22:00", "23:00", "23:45"},
{"00:00", "00:00", "01:00", "01:45"},
{"2:30", "02:30", "03:30", "04:15"}, // normalizes a missing leading zero
}
for _, c := range cases {
db, tier2, offbox := LegTimes(c.start)
if db != c.db || tier2 != c.tier2 || offbox != c.offbox {
t.Errorf("LegTimes(%q) = (%q,%q,%q), want (%q,%q,%q)", c.start, db, tier2, offbox, c.db, c.tier2, c.offbox)
}
}
}
// Group A — invalid input is rejected (LegTimes → empty; Valid → error). Callers pass a value
// already resolved through EffectiveWindow, so an empty result is never rendered.
func TestLegTimes_InvalidRejected(t *testing.T) {
for _, bad := range []string{"25:77", "2200", "", "ab:cd", "24:00", "12:60", "-1:00"} {
db, tier2, offbox := LegTimes(bad)
if db != "" || tier2 != "" || offbox != "" {
t.Errorf("LegTimes(%q) = (%q,%q,%q), want all empty (rejected)", bad, db, tier2, offbox)
}
if Valid(bad) == nil {
t.Errorf("Valid(%q) = nil, want an error", bad)
}
}
if Valid("02:30") != nil {
t.Error("Valid(02:30) returned an error for a well-formed time")
}
}
// Group A — the whole-guest gate span is [W+2h, W+6h), wrap-safe.
func TestGateWindow(t *testing.T) {
if from, to := GateWindow("02:30"); from != "04:30" || to != "08:30" {
t.Errorf("GateWindow(02:30) = (%q,%q), want (04:30,08:30)", from, to)
}
if from, to := GateWindow("23:00"); from != "01:00" || to != "05:00" {
t.Errorf("GateWindow(23:00) = (%q,%q), want (01:00,05:00) — must wrap", from, to)
}
if from, to := GateWindow("bad"); from != "" || to != "" {
t.Errorf("GateWindow(bad) = (%q,%q), want empties", from, to)
}
}
// Group B — precedence: a valid settings value wins over a valid yaml value, which wins over the
// "02:30" default; an empty/corrupt settings value falls through the chain.
func TestEffectiveWindow(t *testing.T) {
cases := []struct{ settingsVal, yamlVal, want string }{
{"22:00", "02:30", "22:00"}, // settings wins over yaml
{"", "03:00", "03:00"}, // yaml when settings empty
{"", "", "02:30"}, // default when both empty
{"garbage", "03:00", "03:00"}, // corrupt settings → fall through to yaml
{"garbage", "nope", "02:30"}, // both invalid → default
{"22:00", "", "22:00"}, // settings valid, yaml empty
}
for _, c := range cases {
if got := EffectiveWindow(c.settingsVal, c.yamlVal); got != c.want {
t.Errorf("EffectiveWindow(%q,%q) = %q, want %q", c.settingsVal, c.yamlVal, got, c.want)
}
}
}