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
}