6e86483185
Fixes the crying-wolf false-fail surfaced by the live hub-enrollment runbook: PVE's guest-start task exits "WARNINGS: 1" for the benign systemd-nesting advisory, and WaitTask treated any non-OK exitstatus as failure, so the verdict was decided by an advisory exit code before the real boot check ran. Every modern-distro restore-test reported pass:false. - proxmox.WaitOptions.AllowWarnings (opt-in; default keeps all callers strict) - restore-test start step accepts warnings, surfaces them, verdict stays waitRunning - RestoreTestResult.StartWarnings/.WarningsRecognized + version-free "enable nesting" recognizer (can't rot back at systemd 258+); GuestAPI.TaskLogTail - hub.RestoreTest.warnings/.warnings_recognized wire fields (consumed by hub v0.7.5) - scheduler logs clean / passed-with-recognized / passed-with-unrecognized warnings - tests: WaitTask warnings matrix; restore-test pass/fail-on-liveness; version-free regression guard (systemd 256-300) Single agent bump 0.6.0 -> 0.7.0 covering the agent half of both task phases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
4.2 KiB
Go
121 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|