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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user