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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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".
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -109,6 +109,26 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Section: customer-configurable backup window (v0.168.0) — one setting drives every nightly leg -->
|
||||
<div class="backup-section-card">
|
||||
<h3>Mentési időablak</h3>
|
||||
<p class="form-hint" style="margin-bottom:1rem">A mentések egymás után futnak: adatbázis-mentés, helyi másolat, távoli mentés, majd a teljes rendszermentés.</p>
|
||||
<form method="POST" action="/backups/window" class="schedule-actions" style="display:flex;align-items:flex-end;gap:.75rem;flex-wrap:wrap">
|
||||
{{.CSRFField}}
|
||||
<div class="form-group" style="margin:0">
|
||||
<label for="window_start">Mentési időablak kezdete</label>
|
||||
<input type="time" id="window_start" name="window_start" value="{{.BackupWindow}}" class="form-control" style="max-width:9rem" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Mentés</button>
|
||||
</form>
|
||||
<ul class="form-hint" style="margin-top:1rem;margin-bottom:0;list-style:none;padding:0;line-height:1.9">
|
||||
<li>Adatbázis-mentés: <strong>{{.BackupLegDB}}</strong></li>
|
||||
<li>Helyi másolat: <strong>{{.BackupLegTier2}}</strong></li>
|
||||
<li>Távoli mentés: <strong>{{.BackupLegOffbox}}</strong></li>
|
||||
<li>Teljes rendszermentés: kb. <strong>{{.BackupGateFrom}}–{{.BackupGateTo}}</strong> között</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: Status overview cards -->
|
||||
<div class="stats-grid backup-page-cards">
|
||||
{{if .Backup.LastDBDump}}
|
||||
|
||||
Reference in New Issue
Block a user