diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f4717b..0b8adf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ ## Changelog +### v0.168.0 — Customer-configurable backup window ("Mentési időablak") (2026-07-24) + +No agent coupling; MinAgent unchanged (the disk-tier gate is controller-side; the agent's cadence-based +`/backup/due` is untouched). New pure package `internal/backupwindow`; touches scheduler, settings, +quiesce, the backup page, and main.go wiring. + +**One setting drives every nightly leg.** A single customer control — **"Mentési időablak kezdete"** +(default = the effective DB-dump time, historically "02:30") — from which every leg derives at FIXED, +never-stored offsets, so misordering is impossible: DB dump at **W**, tier-2 mirror at **W+60m**, +off-box at **W+105m** (wrap-safe across midnight). Precedence: settings > controller.yaml +`db_dump_schedule` > "02:30". + +- **Scheduler seam `UpdateDaily(name, timeStr) bool`** (+ a per-daily-job buffered `resched` channel and + a new select case in `runDailyJob`): a saved window fans out to all three legs and takes effect at the + next scheduling pass **without a restart**. Unknown/non-daily name or invalid time → WARN + false, job + untouched. +- **Disk-tier (whole-guest PBS/vzdump) window gate.** The quiesce loop's scheduled cycles now run only + inside **[W+2h, W+6h)** (wrap-safe, Europe/Budapest wall-clock), with a **safety valve**: if the newest + successful backup is older than cadence+24h (or none exists), the cycle runs regardless of the window — + a box powered on only outside its window never starves. Gate denials log at DEBUG with the window. + **Manual triggers ("Mentés most" / `TriggerNow`) are NEVER gated** (they bypass `runOnce`). The + `Backend.Due` seam now also returns the backup age (from the agent's own `/backup/due` answer) for the + valve; the agent, its cadence, and `/backup/due` semantics are unchanged. +- **Backup page (Áttekintés):** a compact "Mentési időablak" card — time input (value = effective + window) + "Mentés" button, and the derived rows (adatbázis-mentés / helyi másolat / távoli mentés + times, and the "teljes rendszermentés kb. W+2h–W+6h között" line). POST `/backups/window` validates → + saves → `UpdateDaily`×3 → PRG redirect with a Hungarian flash. Behind RequireAuth + CsrfProtect like + its siblings. +- Derived leg/gate times are **computed, never persisted**; no per-leg settings; the offsets are not + exposed in the UI. + +Tests (5 groups, all red-proofed): `LegTimes`/`GateWindow` incl. midnight wrap + invalid-rejected; +`EffectiveWindow` precedence table; `UpdateDaily` mutate+signal + unknown/invalid + goroutine consumes +the reschedule; `scheduledRunAllowed` truth table (inside/outside/valve/wrap/nil-age) + Loop integration +(defer outside / run inside / valve runs / manual never gated); handler valid-save + invalid-rejected. + ### v0.167.1 — Center the sidebar logo (2026-07-24) CSS one-liner + test. `.sidebar-logo` gains `margin: 0 auto` so the 140px logo is horizontally diff --git a/CONTEXT.md b/CONTEXT.md index 18bc45a..6fd6dd3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,25 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-24 (v0.167.0 — outlined logo + favicon, the v0.166.0 Part-4 follow-up) +Last updated: 2026-07-24 (v0.168.0 — customer-configurable backup window "Mentési időablak") + +> **2026-07-24 — v0.168.0 (customer-configurable backup window).** ONE customer setting — the window +> start W ("Mentési időablak kezdete") — drives every nightly leg at FIXED, never-stored offsets so +> misordering is impossible: DB dump at W, tier-2 at W+60m, off-box at W+105m (wrap-safe). **Design +> rulings:** offsets are DERIVED and computed everywhere, never persisted and never exposed in the UI; +> precedence is settings > controller.yaml `db_dump_schedule` > "02:30" (mirrors PasswordHash); a change +> applies WITHOUT restart via the new scheduler seam `UpdateDaily` (per-daily-job buffered `resched` +> chan + a select case in `runDailyJob`). New pure package `internal/backupwindow` holds all the time +> math (ParseHHMM/FmtHHMM/LegTimes/GateWindow/EffectiveWindow). **Disk-tier (whole-guest PBS/vzdump) +> gate:** the quiesce loop's SCHEDULED cycles run only inside [W+2h, W+6h) (wall-clock Europe/Budapest), +> with a safety valve — last successful backup older than cadence+24h (or none) runs regardless, so a +> box only ever on outside its window never starves. **Manual "Mentés most"/TriggerNow is NEVER gated** +> (bypasses runOnce). The `quiesce.Backend.Due` seam now also returns the backup age (from the agent's +> own `/backup/due`); the agent, its cadence, and `/backup/due` are untouched. Window read fresh each +> poll (WindowStartFn) so runtime changes take effect. Cadence defaults to 24h controller-side (the +> response carries no cadence). Backup page gets a "Mentési időablak" card (time input + derived rows + +> the "kb. W+2h–W+6h között" rendszermentés line); POST /backups/window (RequireAuth+CsrfProtect). + > **2026-07-24 — v0.167.0 (outlined logo + favicon — Part 4 unblocked).** Viktor pushed the > text-outlined `logo.svg` to felhom.eu `main` (`be9edb4`); the wordmark is now 17 real `` diff --git a/REUSE.md b/REUSE.md index 419a5aa..b4977ac 100644 --- a/REUSE.md +++ b/REUSE.md @@ -158,7 +158,9 @@ | Symbol | File | Short signature | Use for | Gotchas | |---|---|---|---|---| | `Scheduler.Every` / `Daily` | controller/internal/scheduler/scheduler.go | `(name, interval/"HH:MM", fn)` | ALL background jobs | Daily is Europe/Budapest, DST-safe (`nextDailyRun` avoids Add(24h)); register in main.go block (§5) | -| `getBudapestLocation` | controller/internal/scheduler/scheduler.go | `() *time.Location` | Local-time math | web has its own `getTimezone` (§6) | +| `Scheduler.UpdateDaily` | controller/internal/scheduler/scheduler.go | `(name, "HH:MM") bool` | Retime a daily job at runtime (no restart) | Per-job buffered `resched` chan + select case in `runDailyJob`; false (WARN) on invalid time / unknown-or-non-daily name; read `Schedule` under the mutex in the loop | +| `backupwindow.*` (LegTimes / GateWindow / EffectiveWindow / ParseHHMM / FmtHHMM / Valid) | controller/internal/backupwindow/backupwindow.go | pure `string`↔`int` | Backup-window arithmetic (v0.168.0) | Offsets (W+60m/W+105m, gate W+2h..W+6h) are CONSTANTS — derived, never stored; wrap-safe modulo 1440; `EffectiveWindow(settings, yaml)` = settings>yaml>"02:30" | +| `getBudapestLocation` | controller/internal/scheduler/scheduler.go | `() *time.Location` | Local-time math | web has its own `getTimezone` (§6); quiesce has its own `budapestLocation` (window gate) — 3rd copy, see §6 | | `Server.templateFuncMap` | controller/internal/web/funcmap.go | template.FuncMap | ALL template functions | `stateColor` outputs v2 suffixes `run/progress/warn/neutral/off`; stopped = NEUTRAL not red (operator-approved); `stateLabel` copy is frozen byte-identical (unit-tested) | | `timeAgoStr` | controller/internal/web/funcmap.go | `(s RFC3339 string) string` | Ago-format for STRING timestamps | Exists because `timeAgo(time.Time)` 500'd on strings (v0.93 bug) | | `Server.baseData` / `executeTemplate` | controller/internal/web/handlers.go + server.go | page-data plumbing | New pages | baseData injects nav/alerts/version; templates must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` | @@ -284,7 +286,7 @@ Cross-repo edges: | dir-size ×6 | controller/internal/stacks/delete.go `getDirSizeBytes`/`getDirSizeHuman`; controller/internal/backup/tier2.go `dirSizeBytes` (du -sb); controller/internal/appexport/estimate.go `dirSize`+`duBytes`; controller/internal/appexport/export.go `calcDirSize`; controller/internal/web/handlers.go `dirSizeHuman` | | timeAgo switch body ×2 | controller/internal/web/funcmap.go `timeAgo` vs `timeAgoStr` (identical formatting logic) | | CSRF ×2 | controller/internal/web/csrf.go (session HMAC) vs controller/internal/setup/csrf.go (cookie double-submit) — intentional (pre-auth wizard) but unlabeled | -| Budapest timezone loader ×2 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` | +| Budapest timezone loader ×3 | controller/internal/scheduler/scheduler.go `getBudapestLocation` vs controller/internal/web/funcmap.go `getTimezone` vs controller/internal/quiesce/quiesce.go `budapestLocation` (v0.168.0 window gate — Budapest wall-clock, kept local to avoid a scheduler↔quiesce import edge) | | JSON writers ×5, 3 envelope shapes | api `writeJSON`; web `writeDiskJSON`, `jsonResponse`/`jsonError`, `writeDebugJSON` | | Safe-name validators ×4 | controller/internal/web/validate.go `validStackName`; controller/internal/api/router.go `validStackParam` (same body — api↔web import cycle); controller/internal/backup/offbox.go `isSafeStackName`; controller/internal/appexport/validate.go `ValidateSegment` (strictest) | | DB wait/import ×2 | controller/internal/appbackup/dbdump.go `waitDBReady`/`ImportDump` vs controller/internal/appexport/restore.go `waitForDB`/`importDBDump` | diff --git a/controller/README.md b/controller/README.md index 88eb976..ecda5f0 100644 --- a/controller/README.md +++ b/controller/README.md @@ -185,7 +185,8 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated | **System** | `internal/system/` | System info (`/proc`), CPU collector, mount points, disk usage, FS info | | **Monitor** | `internal/monitor/` | System health checks, storage watchdog, legacy Healthchecks pinger (deprecated) | | **Metrics** | `internal/metrics/` | SQLite time-series store, system + container metric collection | -| **Scheduler** | `internal/scheduler/` | Central job scheduler (periodic + daily, skip-if-running, panic recovery) | +| **Scheduler** | `internal/scheduler/` | Central job scheduler (periodic + daily, skip-if-running, panic recovery). `UpdateDaily` reschedules a daily job at runtime (no restart) via a per-job reschedule signal (v0.168.0). | +| **Backupwindow** | `internal/backupwindow/` | Pure time math for the customer-configurable backup window (v0.168.0): `ParseHHMM`/`FmtHHMM`, `LegTimes` (W / W+60m / W+105m, wrap-safe), `GateWindow` ([W+2h, W+6h)), `EffectiveWindow` (settings > yaml > "02:30"). Offsets are constants — derived, never stored. | | **SelfUpdate** | `internal/selfupdate/` | Version checking (registry), update trigger, state persistence, startup verification | | **Notify** | `internal/notify/` | Email notifications via hub relay, preference sync, per-event cooldowns | | **Report** | `internal/report/` | Hub report builder + HTTP pusher (system, stacks, backup, health) | @@ -655,6 +656,15 @@ retired in v0.126.0 when the moved blocks were legitimately rewritten onto the s The nightly backup has two phases that run sequentially. All paths are **per-drive** — each physical drive gets its own restic repo and per-app DB dump directories. +> **Customer-configurable backup window (v0.168.0).** ONE setting on the backup page — **"Mentési +> időablak kezdete"** (start W, default "02:30") — drives every leg at FIXED, never-stored offsets so +> they can never be misordered: DB dump at **W**, tier-2 mirror at **W+60m**, off-box at **W+105m** +> (wrap-safe). The whole-guest (agent PBS/vzdump) cycle is gated to **[W+2h, W+6h)** with a safety valve +> (runs regardless once the last successful backup is older than cadence+24h, so a box only ever on +> outside its window never starves); **manual "Mentés most" is never gated**. A saved window fans out to +> the three daily legs via `scheduler.UpdateDaily` and takes effect **without a restart**. Precedence: +> settings > controller.yaml `db_dump_schedule` > "02:30". See `internal/backupwindow`. + > **Atomic dump writes (v0.118.0, CAMPAIGN-3 F7).** BOTH dump paths are crash-safe: the DB dump > (`dbdump.go` DumpOne) and the Docker-volume dump (`DumpAppVolumes`) write to a `.tmp` sibling, fsync, > then `os.Rename` over the restore point ONLY on success. A mid-write failure (a NFS cut mid-tar, an diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index e53b3f2..826bdf8 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -25,6 +25,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/appexport" "gitea.dooplex.hu/admin/felhom-controller/internal/assets" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow" "gitea.dooplex.hu/admin/felhom-controller/internal/bootrecon" "gitea.dooplex.hu/admin/felhom-controller/internal/bootstrap" "gitea.dooplex.hu/admin/felhom-controller/internal/channelhealth" @@ -224,7 +225,7 @@ func main() { // --- Quiesce loop (slice 8B): app-consistent backup around the agent vzdump --- // Runs only when the local API is configured (a provisioned guest) and quiesce is enabled. // Recover FIRST (restart any stacks left stopped by a crash mid-quiesce), then start the loop. - quiesceLoop := startQuiesceLoop(ctx, cfg, stackMgr, logger) + quiesceLoop := startQuiesceLoop(ctx, cfg, sett, stackMgr, logger) // --- R-52: boot desired-state reconciliation --- // A deployed app that missed its boot start used to stay down until a human noticed (F5: immich @@ -575,9 +576,15 @@ func main() { // Backup daily jobs if cfg.Backup.Enabled && backupMgr != nil { + // v0.168.0: ONE customer setting (the window start W) drives all three nightly legs at fixed + // offsets — db-dump at W, tier-2 at W+60m, off-box at W+105m — so they can never be misordered. + // The window resolves settings > controller.yaml > "02:30"; a UI save fans out via UpdateDaily. + win := backupwindow.EffectiveWindow(sett.GetBackupWindowStart(), cfg.Backup.DBDumpSchedule) + dbLeg, tier2Leg, offboxLeg := backupwindow.LegTimes(win) + // App-data backup: daily database dumps. Disk-tier (restic snapshots, // cross-drive, integrity check, infra backup) has moved to the host agent. - sched.Daily("db-dump", cfg.Backup.DBDumpSchedule, func(ctx context.Context) error { + sched.Daily("db-dump", dbLeg, func(ctx context.Context) error { err := backupMgr.RunDBDumps(ctx) if err != nil { notifier.NotifyDBDumpFailed("Adatbázis mentés sikertelen", err.Error()) @@ -587,10 +594,11 @@ func main() { return err }) - // Cache refresh: every 5 minutes + // Cache refresh: every 5 minutes. Recompute the effective window each pass so the cached + // "next DB dump" follows a runtime window change (the UI save also refreshes immediately). sched.Every("backup-cache", 5*time.Minute, func(ctx context.Context) error { - nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule) - backupMgr.RefreshCache(nextDBDump) + curDB, _, _ := backupwindow.LegTimes(backupwindow.EffectiveWindow(sett.GetBackupWindowStart(), cfg.Backup.DBDumpSchedule)) + backupMgr.RefreshCache(scheduler.NextDailyRun(curDB)) return nil }) @@ -613,7 +621,7 @@ func main() { }) } }) - sched.Daily("tier2-backup", "03:30", func(ctx context.Context) error { + sched.Daily("tier2-backup", tier2Leg, func(ctx context.Context) error { backupMgr.RunAllTier2() return nil }) @@ -646,7 +654,7 @@ func main() { "A távoli mentési tároló visszaállítva: a régi előzmény félretéve (nem törölve), és egy üres, új tároló jött létre a mostani kulccsal.", map[string]string{"renamed_to": renamedTo}) } }) - sched.Daily("offbox-backup", "04:15", func(ctx context.Context) error { + sched.Daily("offbox-backup", offboxLeg, func(ctx context.Context) error { t := sett.GetOffboxTarget() if t == nil || !t.Enabled || t.Schedule != "daily" || !backupMgr.OffboxConfigured() { return nil // not configured / not scheduled @@ -830,8 +838,8 @@ func main() { // Initial backup cache population (don't block startup) if cfg.Backup.Enabled && backupMgr != nil { go func() { - nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule) - backupMgr.RefreshCache(nextDBDump) + curDB, _, _ := backupwindow.LegTimes(backupwindow.EffectiveWindow(sett.GetBackupWindowStart(), cfg.Backup.DBDumpSchedule)) + backupMgr.RefreshCache(scheduler.NextDailyRun(curDB)) }() } @@ -1652,9 +1660,9 @@ func fileExists(path string) bool { // agentapi response structs). type quiesceBackend struct{ c *agentapi.Client } -func (b quiesceBackend) Due(ctx context.Context) (bool, error) { +func (b quiesceBackend) Due(ctx context.Context) (bool, *int64, error) { r, err := b.c.BackupDue(ctx) - return r.Due, err + return r.Due, r.AgeSecs, err } func (b quiesceBackend) StartBackup(ctx context.Context) (string, error) { r, err := b.c.StartBackup(ctx) @@ -1668,7 +1676,7 @@ func (b quiesceBackend) BackupStatus(ctx context.Context) (string, error) { // startQuiesceLoop wires + starts the slice-8B quiesce loop when the local API is configured and // quiesce is enabled. It Recovers (restarts stacks left stopped by a mid-quiesce crash) before // starting the loop goroutine. Non-fatal: any misconfig disables the loop with a log line. -func startQuiesceLoop(ctx context.Context, cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger) *quiesce.Loop { +func startQuiesceLoop(ctx context.Context, cfg *config.Config, sett *settings.Settings, stackMgr *stacks.Manager, logger *log.Logger) *quiesce.Loop { if cfg.LocalAPI.Endpoint == "" || cfg.LocalAPI.Token == "" { return nil // not a provisioned guest — no agent to back up against } @@ -1692,6 +1700,11 @@ func startQuiesceLoop(ctx context.Context, cfg *config.Config, stackMgr *stacks. StatusPoll: statusPoll, MaxQuiesce: maxQuiesce, Logger: logger, + // Window gate (v0.168.0): read the effective window fresh each poll so a customer change takes + // effect without restart. Scheduled cycles run only inside [W+2h, W+6h), with the safety valve. + WindowStartFn: func() string { + return backupwindow.EffectiveWindow(sett.GetBackupWindowStart(), cfg.Backup.DBDumpSchedule) + }, }) loop.Recover() // crash-safety: restart any stacks stranded-down by a mid-quiesce crash go loop.Run(ctx) diff --git a/controller/internal/backupwindow/backupwindow.go b/controller/internal/backupwindow/backupwindow.go new file mode 100644 index 0000000..118a9b2 --- /dev/null +++ b/controller/internal/backupwindow/backupwindow.go @@ -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. 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 +} diff --git a/controller/internal/backupwindow/backupwindow_test.go b/controller/internal/backupwindow/backupwindow_test.go new file mode 100644 index 0000000..cf52b61 --- /dev/null +++ b/controller/internal/backupwindow/backupwindow_test.go @@ -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) + } + } +} diff --git a/controller/internal/quiesce/quiesce.go b/controller/internal/quiesce/quiesce.go index 92440f1..d764e43 100644 --- a/controller/internal/quiesce/quiesce.go +++ b/controller/internal/quiesce/quiesce.go @@ -20,6 +20,8 @@ import ( "path/filepath" "sync" "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow" ) // ErrBackupInProgress is returned by TriggerNow when a scheduled or manual quiesce cycle is already @@ -27,9 +29,11 @@ import ( var ErrBackupInProgress = errors.New("quiesce: a backup cycle is already in progress") // Backend is the agent local-API surface the loop needs (satisfied by an adapter over -// *agentapi.Client). Kept minimal (bool/string) so the loop is testable with plain fakes. +// *agentapi.Client). Kept minimal (bool/int/string) so the loop is testable with plain fakes. +// Due also returns the age of the newest successful backup in seconds (nil = none yet) — the +// window gate's safety valve reads it so a box powered on only outside its window never starves. type Backend interface { - Due(ctx context.Context) (bool, error) + Due(ctx context.Context) (due bool, ageSecs *int64, err error) StartBackup(ctx context.Context) (jobID string, err error) BackupStatus(ctx context.Context) (phase string, err error) } @@ -68,6 +72,13 @@ type Options struct { StatusPoll time.Duration // how often to poll /backup/status while quiesced MaxQuiesce time.Duration // hard bound on app downtime (unquiesce no matter what) Logger *log.Logger + // WindowStartFn returns the CURRENT effective backup-window start "HH:MM" (customer-configurable, + // so it is read fresh each poll — a window change must take effect without restart). When nil the + // window gate is disabled and a due cycle runs whenever the agent says due (pre-v0.168.0 behavior). + WindowStartFn func() string + // Cadence is the agent's backup cadence, used only by the gate's safety valve (run regardless of + // the window once the last successful backup is older than Cadence+24h). Defaults to 24h. + Cadence time.Duration } // Loop is the quiesce background loop. @@ -80,6 +91,9 @@ type Loop struct { maxQuiesce time.Duration logger *log.Logger now func() time.Time + // windowStartFn (nil = gate disabled) + cadence drive the scheduled-cycle window gate (Part 3). + windowStartFn func() string + cadence time.Duration // mu single-flights the quiesce cycle across the scheduled loop AND the manual trigger, so the // two can never stop the same stacks concurrently (the persisted marker covers crash-safety across // restarts; this covers concurrency within the process — which a manual trigger introduces). @@ -100,10 +114,14 @@ func New(o Options) *Loop { if o.Logger == nil { o.Logger = log.Default() } + if o.Cadence <= 0 { + o.Cadence = 24 * time.Hour + } return &Loop{ backend: o.Backend, stacks: o.Stacks, markerPath: o.MarkerPath, poll: o.Poll, statusPoll: o.StatusPoll, maxQuiesce: o.MaxQuiesce, logger: o.Logger, now: time.Now, + windowStartFn: o.WindowStartFn, cadence: o.Cadence, } } @@ -159,7 +177,7 @@ func (l *Loop) runOnce(ctx context.Context) error { return nil } - due, err := l.backend.Due(ctx) + due, ageSecs, err := l.backend.Due(ctx) if err != nil { return fmt.Errorf("check due: %w", err) } @@ -167,6 +185,17 @@ func (l *Loop) runOnce(ctx context.Context) error { return nil } + // Window gate (Part 3) — SCHEDULED path only. TriggerNow calls quiesceAndPoll directly and is + // never gated. Disabled when no window fn is wired (pre-v0.168.0 behavior). + if l.windowStartFn != nil { + window := l.windowStartFn() + if !scheduledRunAllowed(l.now().In(budapestLocation()), window, ageSecs, l.cadence) { + from, to := gateBounds(window) + l.logger.Printf("[DEBUG] [quiesce] scheduled backup due but outside the backup window [%s–%s) — deferring to the next poll inside it", from, to) + return nil + } + } + return l.quiesceAndPoll(ctx) } @@ -286,6 +315,63 @@ func (l *Loop) quiesceAndPoll(ctx context.Context) error { } } +// ---- window gate (Part 3, v0.168.0) ----------------------------------------------------- + +var ( + quiesceBudapest *time.Location + quiesceBudapestOnce sync.Once +) + +func budapestLocation() *time.Location { + quiesceBudapestOnce.Do(func() { + loc, err := time.LoadLocation("Europe/Budapest") + if err != nil { + quiesceBudapest = time.UTC + return + } + quiesceBudapest = loc + }) + return quiesceBudapest +} + +const ( + gateOpenOffsetMin = 120 // gate opens at W+2h + gateSpanMin = 240 // 4h span → [W+2h, W+6h) +) + +// scheduledRunAllowed decides whether a DUE, scheduled whole-guest backup may run at `now` (passed by +// the caller as Budapest wall-clock — only its hour/minute are read). True when now is inside the gate +// window [W+2h, W+6h); otherwise true ONLY if the safety valve holds — the newest successful backup is +// missing (nil) or older than cadence+24h — so a box powered on only outside its window never starves. +// An unparseable window fails OPEN (allow) rather than block backups forever. +func scheduledRunAllowed(now time.Time, windowStart string, lastAgeSecs *int64, cadence time.Duration) bool { + startMin, err := backupwindow.ParseHHMM(windowStart) + if err != nil { + return true + } + nowMin := now.Hour()*60 + now.Minute() + if within(nowMin, mod1440(startMin+gateOpenOffsetMin), gateSpanMin) { + return true + } + // Outside the window: only the safety valve may run it. + if lastAgeSecs == nil { + return true // no recorded backup yet — never withhold the first one + } + return time.Duration(*lastAgeSecs)*time.Second > cadence+24*time.Hour +} + +// gateBounds returns the gate window [W+2h, W+6h) as HH:MM for the deferral log line. +func gateBounds(windowStart string) (from, to string) { + return backupwindow.GateWindow(windowStart) +} + +func mod1440(m int) int { return ((m % 1440) + 1440) % 1440 } + +// within reports whether minute-of-day p falls in [start, start+span) modulo 24h (wrap-safe). +func within(p, start, span int) bool { + return mod1440(p-start) < span +} + func (l *Loop) restartAll(stacks []string) { for _, s := range stacks { if err := l.stacks.StartStack(s); err != nil { diff --git a/controller/internal/quiesce/quiesce_8b2_test.go b/controller/internal/quiesce/quiesce_8b2_test.go index 7c68bda..2f2b517 100644 --- a/controller/internal/quiesce/quiesce_8b2_test.go +++ b/controller/internal/quiesce/quiesce_8b2_test.go @@ -34,8 +34,8 @@ type eventBackend struct { i int } -func (b *eventBackend) Due(context.Context) (bool, error) { return true, nil } -func (b *eventBackend) StartBackup(context.Context) (string, error) { return "job-1", nil } +func (b *eventBackend) Due(context.Context) (bool, *int64, error) { return true, nil, nil } +func (b *eventBackend) StartBackup(context.Context) (string, error) { return "job-1", nil } func (b *eventBackend) BackupStatus(context.Context) (string, error) { ph := b.phases[len(b.phases)-1] if b.i < len(b.phases) { diff --git a/controller/internal/quiesce/quiesce_test.go b/controller/internal/quiesce/quiesce_test.go index 478a8ab..873bf81 100644 --- a/controller/internal/quiesce/quiesce_test.go +++ b/controller/internal/quiesce/quiesce_test.go @@ -54,6 +54,7 @@ func (f *fakeStacks) stoppedNames() []string { // fakeBackend drives the agent-side responses. type fakeBackend struct { due bool + dueAge *int64 // age of newest successful backup (nil = none); gate safety-valve input dueErr error startErr error jobID string @@ -64,7 +65,7 @@ type fakeBackend struct { mu sync.Mutex } -func (b *fakeBackend) Due(context.Context) (bool, error) { return b.due, b.dueErr } +func (b *fakeBackend) Due(context.Context) (bool, *int64, error) { return b.due, b.dueAge, b.dueErr } func (b *fakeBackend) StartBackup(context.Context) (string, error) { b.mu.Lock() b.startCalls++ diff --git a/controller/internal/quiesce/quiesce_window_test.go b/controller/internal/quiesce/quiesce_window_test.go new file mode 100644 index 0000000..c319007 --- /dev/null +++ b/controller/internal/quiesce/quiesce_window_test.go @@ -0,0 +1,129 @@ +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") +} diff --git a/controller/internal/scheduler/scheduler.go b/controller/internal/scheduler/scheduler.go index bb22ce1..34faf7c 100644 --- a/controller/internal/scheduler/scheduler.go +++ b/controller/internal/scheduler/scheduler.go @@ -37,6 +37,10 @@ type Job struct { LastRun time.Time LastErr error Running bool + // resched (daily jobs only, buffered cap 1) is the runtime-reschedule signal: UpdateDaily mutates + // Schedule under the mutex and non-blocking-sends here so runDailyJob wakes and recomputes its next + // run immediately, instead of waiting out the old timer. Immutable after job creation. + resched chan struct{} } // Scheduler manages periodic and daily jobs. @@ -120,6 +124,7 @@ func (s *Scheduler) Daily(name string, timeStr string, fn JobFunc) { Name: name, Fn: fn, Schedule: timeStr, + resched: make(chan struct{}, 1), } s.jobs = append(s.jobs, job) @@ -133,6 +138,50 @@ func (s *Scheduler) Daily(name string, timeStr string, fn JobFunc) { } } +// UpdateDaily changes the schedule of an already-registered daily job at runtime (no restart) and +// wakes its goroutine so the new time takes effect at the next scheduling pass. Returns false (and +// logs WARN) on an invalid time or an unknown/non-daily job name — the job is left untouched. +func (s *Scheduler) UpdateDaily(name, timeStr string) bool { + if _, _, err := parseDailyTime(timeStr); err != nil { + s.logger.Printf("[WARN] [scheduler] UpdateDaily %s: invalid schedule %q: %v — ignored", name, timeStr, err) + return false + } + + s.mu.Lock() + var job *Job + for _, j := range s.jobs { + if j.Name == name && j.Schedule != "" { + job = j + break + } + } + if job == nil { + s.mu.Unlock() + s.logger.Printf("[WARN] [scheduler] UpdateDaily: no daily job named %q — ignored", name) + return false + } + old := job.Schedule + if old == timeStr { + s.mu.Unlock() + return true // no-op: already at this time + } + job.Schedule = timeStr + ch := job.resched + s.mu.Unlock() + + s.logger.Printf("[INFO] [scheduler] Daily job %s rescheduled %s → %s (next run %s)", + name, old, timeStr, nextDailyRun(timeStr).Format("2006-01-02 15:04 MST")) + // Non-blocking wake: the buffered slot coalesces bursts (three legs updated back-to-back each + // signal their own job); if the goroutine hasn't drained yet the recompute already covers this. + if ch != nil { + select { + case ch <- struct{}{}: + default: + } + } + return true +} + // Start begins running all registered jobs. Safe to call only once. func (s *Scheduler) Start(ctx context.Context) { s.mu.Lock() @@ -234,7 +283,13 @@ func (s *Scheduler) runDailyJob(job *Job) { defer s.wg.Done() for { - nextRun := nextDailyRun(job.Schedule) + // Read Schedule under the mutex — UpdateDaily mutates it concurrently. resched is immutable + // after creation, so it is safe to read once here. + s.mu.Lock() + schedule := job.Schedule + s.mu.Unlock() + + nextRun := nextDailyRun(schedule) waitDuration := time.Until(nextRun) if waitDuration < 0 { @@ -249,6 +304,11 @@ func (s *Scheduler) runDailyJob(job *Job) { timer.Stop() s.dbg("daily job %s: context cancelled, stopping", job.Name) return + case <-job.resched: + // Runtime reschedule: abandon the current timer and recompute against the new Schedule. + timer.Stop() + s.dbg("daily job %s: rescheduled — recomputing next run", job.Name) + continue case <-timer.C: s.executeJob(job, false) } diff --git a/controller/internal/scheduler/scheduler_test.go b/controller/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..705fd98 --- /dev/null +++ b/controller/internal/scheduler/scheduler_test.go @@ -0,0 +1,81 @@ +package scheduler + +import ( + "context" + "io" + "log" + "testing" + "time" +) + +func discardScheduler() *Scheduler { return New(log.New(io.Discard, "", 0)) } + +func noopJob(context.Context) error { return nil } + +// farFuture returns an HH:MM roughly n hours ahead in Budapest, so a registered daily job parks on a +// long timer (never fires during a sub-second test) yet is a valid, distinct schedule to switch to. +func farFuture(hoursAhead int) string { + return time.Now().In(getBudapestLocation()).Add(time.Duration(hoursAhead) * time.Hour).Format("15:04") +} + +// Group C — UpdateDaily mutates the schedule and delivers the reschedule signal. Asserted on a +// NOT-started scheduler so no goroutine competes for the buffered signal (race-free). +func TestUpdateDaily_MutatesAndSignals(t *testing.T) { + s := discardScheduler() + s.Daily("db-dump", "02:30", noopJob) + + if !s.UpdateDaily("db-dump", "22:00") { + t.Fatal("UpdateDaily returned false for a valid change") + } + if got := s.jobs[0].Schedule; got != "22:00" { + t.Errorf("Schedule = %q, want 22:00", got) + } + // The buffered reschedule signal must be present (no goroutine drained it). + select { + case <-s.jobs[0].resched: + default: + t.Error("no reschedule signal was sent by UpdateDaily") + } +} + +// Group C — unknown or non-daily job name, and an invalid time, both return false without panicking +// and leave everything untouched. +func TestUpdateDaily_UnknownAndInvalid(t *testing.T) { + s := discardScheduler() + s.Daily("db-dump", "02:30", noopJob) + + if s.UpdateDaily("does-not-exist", "03:00") { + t.Error("UpdateDaily on an unknown job returned true") + } + if s.UpdateDaily("db-dump", "25:99") { + t.Error("UpdateDaily with an invalid time returned true") + } + if got := s.jobs[0].Schedule; got != "02:30" { + t.Errorf("Schedule was mutated by a rejected update: %q", got) + } +} + +// Group C — a running daily goroutine picks up the new time on the next pass: it CONSUMES the +// reschedule signal from the select and recomputes. Red-proof: remove the `case <-job.resched` +// from runDailyJob's select and this fails (the signal stays buffered — never consumed). +func TestUpdateDaily_GoroutineConsumesReschedule(t *testing.T) { + s := discardScheduler() + s.Daily("db-dump", farFuture(6), noopJob) // parked on a ~6h timer + + ctx, cancel := context.WithCancel(context.Background()) + s.Start(ctx) + + if !s.UpdateDaily("db-dump", farFuture(8)) { + t.Fatal("UpdateDaily returned false") + } + time.Sleep(150 * time.Millisecond) // let the goroutine wake on resched and recompute + cancel() + s.Stop() // waits for the goroutine to exit — after this, reading resched is race-free + + select { + case <-s.jobs[0].resched: + t.Fatal("reschedule signal still buffered — runDailyJob never consumed it (no resched case)") + default: + // drained by the goroutine → immediacy works + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 19b6776..606a941 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -61,6 +61,11 @@ type Settings struct { // Per-app backup preferences AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"` + // Customer-configurable backup-window start "HH:MM" (v0.168.0). "" = use controller.yaml + // db_dump_schedule (then the "02:30" default). Every nightly leg derives from this at fixed + // offsets; overrides yaml when a valid value is present (mirrors PasswordHash precedence). + BackupWindowStart string `json:"backup_window_start,omitempty"` + // Storage paths registry StoragePaths []StoragePath `json:"storage_paths,omitempty"` @@ -146,7 +151,7 @@ type AppBackupPrefs struct { type OffboxTarget struct { Enabled bool `json:"enabled"` Host string `json:"host"` - Port int `json:"port"` // default 22 + Port int `json:"port"` // default 22 User string `json:"user"` RepoPath string `json:"repo_path"` // absolute path on the NAS, e.g. /volume1/felhom-backup/repo Schedule string `json:"schedule"` // "daily" | "manual" @@ -217,7 +222,7 @@ type CrossDriveBackup struct { LastRun string `json:"last_run,omitempty"` // RFC3339 LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running" LastError string `json:"last_error,omitempty"` - LastWarning string `json:"last_warning,omitempty"` // Tier-2 3b: capture-gap / state-only notice (Hungarian) + LastWarning string `json:"last_warning,omitempty"` // Tier-2 3b: capture-gap / state-only notice (Hungarian) LastDuration string `json:"last_duration,omitempty"` // "2m34s" LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB" @@ -492,6 +497,26 @@ func (s *Settings) SetPasswordHash(hash string) error { // ── Guest launcher share (v0.165.0) ────────────────────────────────────────────── +// ── Backup window (v0.168.0) ───────────────────────────────────────────────────── + +// GetBackupWindowStart returns the customer-set backup-window start "HH:MM" ("" = fall back to +// controller.yaml, then the default — resolve via backupwindow.EffectiveWindow, never in isolation). +func (s *Settings) GetBackupWindowStart() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.BackupWindowStart +} + +// SetBackupWindowStart stores (or clears, on "") the backup-window start and saves. The caller +// validates the HH:MM format first (the scheduler/backupwindow gate) and fans the change out to the +// three daily legs via UpdateDaily — this only persists the single source-of-truth value. +func (s *Settings) SetBackupWindowStart(start string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.BackupWindowStart = start + return s.save() +} + // GetLauncherShareToken returns the guest-launcher capability token ("" = sharing disabled). func (s *Settings) GetLauncherShareToken() string { s.mu.RLock() diff --git a/controller/internal/web/backup_handlers.go b/controller/internal/web/backup_handlers.go index 3aee673..aaa574f 100644 --- a/controller/internal/web/backup_handlers.go +++ b/controller/internal/web/backup_handlers.go @@ -4,13 +4,79 @@ import ( "context" "errors" "net/http" + "net/url" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow" "gitea.dooplex.hu/admin/felhom-controller/internal/quiesce" + "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" ) +// effectiveBackupWindow resolves the active backup-window start (settings > controller.yaml > +// "02:30") for this server. Every nightly-leg display and the DB-dump next-run derive from it. +func (s *Server) effectiveBackupWindow() string { + return backupwindow.EffectiveWindow(s.settings.GetBackupWindowStart(), s.cfg.Backup.DBDumpSchedule) +} + +// backupWindowData injects the customer-configurable-window view onto the Áttekintés page: the +// effective start, the three derived leg times (DB / helyi másolat / távoli mentés), and the +// whole-guest gate span [W+2h, W+6h). The offsets are DERIVED here, never stored. +func (s *Server) backupWindowData(data map[string]interface{}) { + win := s.effectiveBackupWindow() + db, tier2, offbox := backupwindow.LegTimes(win) + from, to := backupwindow.GateWindow(win) + data["BackupWindow"] = win + data["BackupLegDB"] = db + data["BackupLegTier2"] = tier2 + data["BackupLegOffbox"] = offbox + data["BackupGateFrom"] = from + data["BackupGateTo"] = to +} + +// backupWindowSaveHandler persists a new backup-window start and fans it out to the three daily legs +// live (no restart) via UpdateDaily. POST /backups/window (behind RequireAuth + CsrfProtect). On an +// invalid time nothing is stored and the jobs are untouched. +func (s *Server) backupWindowSaveHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + start := strings.TrimSpace(r.FormValue("window_start")) + if backupwindow.Valid(start) != nil { + s.backupWindowRedirect(w, r, "", "Érvénytelen időpont. Használja a ÓÓ:PP formátumot (például 02:30).") + return + } + if err := s.settings.SetBackupWindowStart(start); err != nil { + s.logger.Printf("[ERROR] [web] backup window save failed: %v", err) + s.backupWindowRedirect(w, r, "", "A mentési időablak mentése nem sikerült.") + return + } + // Fan out to the three daily legs at their fixed offsets — takes effect at the next scheduling + // pass (no restart). The scheduler wakes each job via its reschedule signal. + db, tier2, offbox := backupwindow.LegTimes(start) + if s.scheduler != nil { + s.scheduler.UpdateDaily("db-dump", db) + s.scheduler.UpdateDaily("tier2-backup", tier2) + s.scheduler.UpdateDaily("offbox-backup", offbox) + } + // Refresh the cached "next DB dump" so the display updates immediately, not at the next 5m tick. + if s.backupMgr != nil { + s.backupMgr.RefreshCache(scheduler.NextDailyRun(db)) + } + s.logger.Printf("[INFO] [web] backup window set to %s (legs %s/%s/%s)", start, db, tier2, offbox) + s.backupWindowRedirect(w, r, "Mentési időablak frissítve.", "") +} + +// backupWindowRedirect PRG-redirects back to the Áttekintés page with a success or error flash. +func (s *Server) backupWindowRedirect(w http.ResponseWriter, r *http.Request, flash, flashErr string) { + dest := "/backups" + if flashErr != "" { + dest += "?flash_error=" + url.QueryEscape(flashErr) + } else if flash != "" { + dest += "?flash=" + url.QueryEscape(flash) + } + http.Redirect(w, r, dest, http.StatusSeeOther) +} + // Whole-guest backup visibility + manual trigger (spec Part 2). The agent owns whole-guest // vzdump/PBS backup; the controller is a read-only window onto it (GET /backup/{status,due}, // /restore-test/status) plus a "Mentés most" trigger that goes through the quiesce loop (the @@ -41,8 +107,8 @@ type guestBackupView struct { DueReason string AgeHours int64 // age of the newest successful backup, hours (for "X órája") - HasRestoreTest bool - RestorePass bool + HasRestoreTest bool + RestorePass bool RestoreVerified string RestoreTestedAt time.Time diff --git a/controller/internal/web/backup_window_test.go b/controller/internal/web/backup_window_test.go new file mode 100644 index 0000000..a313ff7 --- /dev/null +++ b/controller/internal/web/backup_window_test.go @@ -0,0 +1,96 @@ +package web + +import ( + "context" + "io" + "log" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" +) + +// schedWith3Legs builds a real scheduler carrying the three daily legs at their default times, so +// UpdateDaily has jobs to find. +func schedWith3Legs() *scheduler.Scheduler { + s := scheduler.New(log.New(io.Discard, "", 0)) + noop := func(context.Context) error { return nil } + s.Daily("db-dump", "02:30", noop) + s.Daily("tier2-backup", "03:30", noop) + s.Daily("offbox-backup", "04:15", noop) + return s +} + +func legTimes(t *testing.T, sch *scheduler.Scheduler, name string) string { + t.Helper() + for _, j := range sch.GetJobs() { + if j.Name == name { + return j.Schedule + } + } + t.Fatalf("job %q not found", name) + return "" +} + +// Group E — a valid save writes the setting, fans the three legs out via UpdateDaily, and redirects +// with a success flash. Red-proof: skip the Valid() check in the handler and an invalid value would +// be written — TestBackupWindowSave_Invalid then fails. +func TestBackupWindowSave_Valid(t *testing.T) { + s := testServer(t) + s.scheduler = schedWith3Legs() + + req := httptest.NewRequest(http.MethodPost, "/backups/window", strings.NewReader("window_start=22:00")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + s.backupWindowSaveHandler(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); !strings.Contains(loc, "flash=") || strings.Contains(loc, "flash_error=") { + t.Errorf("redirect Location = %q, want a success flash", loc) + } + if got := s.settings.GetBackupWindowStart(); got != "22:00" { + t.Errorf("settings BackupWindowStart = %q, want 22:00", got) + } + // The three legs must have been rescheduled to W / W+60m / W+105m. + if got := legTimes(t, s.scheduler, "db-dump"); got != "22:00" { + t.Errorf("db-dump = %q, want 22:00", got) + } + if got := legTimes(t, s.scheduler, "tier2-backup"); got != "23:00" { + t.Errorf("tier2-backup = %q, want 23:00", got) + } + if got := legTimes(t, s.scheduler, "offbox-backup"); got != "23:45" { + t.Errorf("offbox-backup = %q, want 23:45", got) + } +} + +// Group E — an invalid time stores nothing, leaves the legs untouched, and redirects with an error +// flash. +func TestBackupWindowSave_Invalid(t *testing.T) { + s := testServer(t) + s.scheduler = schedWith3Legs() + + req := httptest.NewRequest(http.MethodPost, "/backups/window", strings.NewReader("window_start=2500")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + s.backupWindowSaveHandler(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); !strings.Contains(loc, "flash_error=") { + t.Errorf("redirect Location = %q, want an error flash", loc) + } + if got := s.settings.GetBackupWindowStart(); got != "" { + t.Errorf("settings BackupWindowStart = %q, want empty (nothing stored)", got) + } + if got := legTimes(t, s.scheduler, "db-dump"); got != "02:30" { + t.Errorf("db-dump = %q, want 02:30 (unchanged)", got) + } + if got := legTimes(t, s.scheduler, "offbox-backup"); got != "04:15" { + t.Errorf("offbox-backup = %q, want 04:15 (unchanged)", got) + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 6758077..0b51529 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -174,7 +174,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) { // Backup status data["BackupEnabled"] = s.cfg.Backup.Enabled if s.backupMgr != nil { - nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) + nextDBDump := scheduler.NextDailyRun(s.effectiveBackupWindow()) fullStatus := s.backupMgr.GetFullStatus(nextDBDump) data["DBDumpStatus"] = fullStatus.LastDBDump // F3 (AUDIT-vacation-remote-ops-2026-07-20): the card's "Utolsó mentés" row branches on @@ -731,7 +731,7 @@ func (s *Server) backupsCommonData(page, title string, r *http.Request) map[stri data["Backup"] = nil return data } - nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule) + nextDBDump := scheduler.NextDailyRun(s.effectiveBackupWindow()) fullStatus := s.backupMgr.GetFullStatus(nextDBDump) // Pass flash messages from query params (set by redirect handlers) @@ -818,6 +818,9 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) { // Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section. data["GuestBackup"] = s.loadGuestBackup(r.Context()) + // Customer-configurable backup window (v0.168.0): effective start + derived leg/gate times. + s.backupWindowData(data) + if fullStatus, ok := data["Backup"].(*backup.FullBackupStatus); ok && fullStatus != nil { // DB-section state — honest messaging for embedded-DB-only boxes (SQLite etc.): // "dumps" (real dumps) | "pending" (discovered, first run tonight) | "embedded". diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 2a21069..3c9b72b 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -111,9 +111,9 @@ type Server struct { // Same S-5 law as sambaAddrFn: live-computed per render/dump, stored nowhere. guestGatewayFn func() string guestNetFn func() stacks.GuestNetSnapshot - netAgentFn func() (netAgent, error) + netAgentFn func() (netAgent, error) // fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0). - fabUpload uploadState + fabUpload uploadState netProbeFn func(ctx context.Context, dir string) probeOutcome netListFn func(ctx context.Context) ([]agentapi.NetworkMountStatus, error) // agentLogsFn is the Debug-page agent-tab seam (v0.116.0). nil → the shared @@ -383,6 +383,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.stacksHandler(w, r) case path == "/backups": s.backupsHandler(w, r) + case path == "/backups/window" && r.Method == http.MethodPost: + s.backupWindowSaveHandler(w, r) // v0.124.0 IA split: the backups page's four sub-pages (old /backups deep links keep working — // /backups itself is the Áttekintés page). case path == "/backups/remote": diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 9b9bfad..800e888 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -109,6 +109,26 @@ {{end}} + +
+

Mentési időablak

+

A mentések egymás után futnak: adatbázis-mentés, helyi másolat, távoli mentés, majd a teljes rendszermentés.

+
+ {{.CSRFField}} +
+ + +
+ +
+
    +
  • Adatbázis-mentés: {{.BackupLegDB}}
  • +
  • Helyi másolat: {{.BackupLegTier2}}
  • +
  • Távoli mentés: {{.BackupLegOffbox}}
  • +
  • Teljes rendszermentés: kb. {{.BackupGateFrom}}–{{.BackupGateTo}} között
  • +
+
+
{{if .Backup.LastDBDump}}