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
+68 -2
View File
@@ -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