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) // SpecBuilder yields the RestoreTestSpec for ONE run, given the archive that was picked. // // R-85 (1.1): this REPLACES a frozen spec value. It used to be built by an immediately-invoked // function at daemon start, so `storageTier()` and `restoreTaskTimeout()` were evaluated ONCE and // the resulting value reused for every run for the lifetime of the process. Two consequences: // - nothing tier-varying was expressible at all (the offsite tier could never be scheduled), and // - it was a latent staleness bug in its own right — a storage-type or config change did not take // effect until the daemon restarted. // // The archive is passed in because the tier MUST be derived from it (the v0.100.0 rule), never from // the configured target: deriving it from config is what produced the 600 s false failure when a // PBS archive was classified "local" and got the 10-minute local wait. type SpecBuilder func(ctx context.Context, archive string) reconcile.RestoreTestSpec // 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 SpecBuilder // R-85: evaluated PER RUN, never frozen at construction cadence time.Duration logger *slog.Logger now func() time.Time } // SchedulerOptions configures a Scheduler. type SchedulerOptions struct { Runner RestoreTestRunner Pick CandidatePicker Store *Store // Spec builds the run's spec (RestoreStorage, ScratchMin/Max, SourceTier, timeouts) from the // picked archive. Called ONCE PER RUN — see SpecBuilder for why it is not a value. Spec SpecBuilder 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.spec == 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) { if s.spec == nil { // Defensive: Run() already refuses to start without a SpecBuilder, but tick is also // reachable directly. Skipping loudly beats panicking the daemon goroutine — a missing // spec must cost a restore-test, never the agent. s.logger.Error("backup: restore-test has no spec builder — skipping (this is a wiring bug)") return } 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 } // R-85: build the spec for THIS run, from THIS archive. Never a frozen value. spec := s.spec(ctx, archive) 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) } }