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
+25 -12
View File
@@ -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)