package backup import ( "context" "log/slog" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/reconcile" ) // RestoreTestRunner is the reconcile-engine seam the scheduler drives (*reconcile.Engine // satisfies it). Kept narrow so the scheduler is unit-testable with a fake. type RestoreTestRunner interface { RunRestoreTest(ctx context.Context, spec reconcile.RestoreTestSpec) reconcile.RestoreTestResult } // CandidatePicker resolves the archive volid to restore-test (newest backup), or "" when // there is none yet (the tick then no-ops). type CandidatePicker func(ctx context.Context) (string, error) // Scheduler runs the self-restore-test on an agent-internal cadence. It is the fourth daemon // goroutine; it does real restore→boot→destroy, so it only runs when the cadence is enabled // AND a valid scratch band is configured (validated by the caller before construction). type Scheduler struct { runner RestoreTestRunner pick CandidatePicker store *Store spec reconcile.RestoreTestSpec // archive is filled per-tick cadence time.Duration logger *slog.Logger now func() time.Time } // SchedulerOptions configures a Scheduler. type SchedulerOptions struct { Runner RestoreTestRunner Pick CandidatePicker Store *Store Spec reconcile.RestoreTestSpec // RestoreStorage, ScratchMin/Max, SourceTier, BootTimeout Cadence time.Duration // 0 → disabled Logger *slog.Logger } // NewScheduler builds a Scheduler. func NewScheduler(opts SchedulerOptions) *Scheduler { logger := opts.Logger if logger == nil { logger = slog.Default() } return &Scheduler{ runner: opts.Runner, pick: opts.Pick, store: opts.Store, spec: opts.Spec, cadence: opts.Cadence, logger: logger, now: func() time.Time { return time.Now().UTC() }, } } // Run fires a restore-test on the cadence until ctx is cancelled. A 0 cadence disables it // (the goroutine just waits for shutdown). It does NOT fire immediately on start (a restore // is heavy; the first runs one interval in) — on-demand runs use the selftest harness. // Returns nil on ctx cancellation. func (s *Scheduler) Run(ctx context.Context) error { if s.cadence <= 0 || s.runner == nil || s.pick == nil { s.logger.Info("backup: restore-test cadence disabled") <-ctx.Done() return nil } s.logger.Info("backup: restore-test scheduler starting", "cadence", s.cadence) t := time.NewTicker(s.cadence) defer t.Stop() for { select { case <-ctx.Done(): s.logger.Info("backup: restore-test scheduler shutting down", "reason", ctx.Err()) return nil case <-t.C: s.tick(ctx) } } } // tick runs one scheduled restore-test: pick a backup → run → record. No-ops cleanly when // no backup exists yet. Deterministic given s.now — tests call it directly. func (s *Scheduler) tick(ctx context.Context) { archive, err := s.pick(ctx) if err != nil { s.logger.Warn("backup: restore-test could not pick a candidate; skipping", "err", err) return } if archive == "" { s.logger.Info("backup: restore-test skipped; no backup available yet") return } spec := s.spec spec.Archive = archive res := s.runner.RunRestoreTest(ctx, spec) if res.Skipped { return // already logged by the engine (no free scratch VMID) } rt := ToHubRestoreTest(res, s.now()) s.store.RecordRestoreTest(rt) switch { case !rt.Pass: // A failing restore-test is the loudest DR signal there is. s.logger.Error("backup: scheduled restore-test FAILED", "archive", rt.SourceArchive, "err", rt.Error) case len(res.StartWarnings) == 0: s.logger.Info("backup: scheduled restore-test passed", "archive", rt.SourceArchive, "duration_s", rt.DurationSeconds) case res.WarningsRecognized: // Passed; the only warnings are the known-benign (e.g. systemd-nesting) advisory. s.logger.Info("backup: scheduled restore-test passed with warnings (recognized)", "archive", rt.SourceArchive, "duration_s", rt.DurationSeconds, "warnings", res.StartWarnings) default: // Passed liveness, but an UNRECOGNIZED start warning stood out — worth an operator look. s.logger.Warn("backup: scheduled restore-test passed with UNRECOGNIZED warnings", "archive", rt.SourceArchive, "duration_s", rt.DurationSeconds, "warnings", res.StartWarnings) } }