043c7622bc
The scheduler could only ever see cfg.Backup.BackupTarget(), so the offsite tier's archives were never candidates — which is why demo-hp's DR tier reported 'applied' with zero snapshots for five days and nobody noticed. Selection: oldest-first (operator ruling, Option 1). Never-proven sorts first, which is where the offsite tier starts. Ties break on target id so ordering is deterministic rather than following Go's randomised map order. Rotation credit only on SUCCESS — a permanently failing tier must keep sorting first, not look freshly proven and stop being retried. - backup.RestoreTestState: persisted last-success per tier (atomic tmp+rename). This genuinely needs persistence unlike R-84: R-84 had ground truth to consult (the archive is still on the storage), whereas a restore-test destroys its scratch and leaves no artifact. Corrupt/missing file -> 'nothing proven'. - backup.InFlight: host-wide one-heavy-op gate shared with the local-API backup path. A LINK concern, not a lock one — an offsite restore pulls multi-GB over the same tunnel a backup pushes one, and at ~33 MB/min both drift toward timeout, which is how a healthy tier gets recorded as failed. Callers DEFER, never cancel. - PickRestoreCandidateOn: newest archive on a named tier; '' is not an error, or every fresh box looks broken for its first week. - An empty tier is skipped and the next tried; it cannot starve, since it is still least-recently-proven once it has an archive. - POST /backup joins the gate (409 naming the holder). Red-proofs A/E/F observed with the documented text. Full suite green (29 packages, rc=0).
238 lines
10 KiB
Go
238 lines
10 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)
|
|
|
|
// 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
|
|
|
|
// TierPicker resolves the newest archive on a NAMED tier, or "" when that tier holds none.
|
|
// (*BackupRunner).PickRestoreCandidateOn satisfies it. "" must NOT be an error — a brand-new
|
|
// offsite tier legitimately has nothing to restore yet.
|
|
type TierPicker func(ctx context.Context, target string) (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 SpecBuilder // R-85: evaluated PER RUN, never frozen at construction
|
|
cadence time.Duration
|
|
logger *slog.Logger
|
|
now func() time.Time
|
|
|
|
// R-85 tier rotation. All optional: without them the scheduler behaves exactly as before
|
|
// (single tier via `pick`), which keeps every existing caller and test working untouched.
|
|
tiers []string // configured tier target ids, primary first
|
|
tierPick TierPicker // newest archive on a named tier
|
|
rtState *RestoreTestState // persisted last-successful-per-tier (drives oldest-first)
|
|
inFlight *InFlight // shared with the backup path — Scenario F
|
|
}
|
|
|
|
// 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
|
|
|
|
// R-85 (all optional — omit for the pre-R-85 single-tier behaviour):
|
|
// Tiers are the configured tier target ids (primary first); TierPick resolves an archive on a
|
|
// named tier; State persists last-successful-per-tier; InFlight is the shared one-heavy-op gate.
|
|
Tiers []string
|
|
TierPick TierPicker
|
|
State *RestoreTestState
|
|
InFlight *InFlight
|
|
}
|
|
|
|
// 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() },
|
|
tiers: append([]string(nil), opts.Tiers...),
|
|
tierPick: opts.TierPick,
|
|
rtState: opts.State,
|
|
inFlight: opts.InFlight,
|
|
}
|
|
}
|
|
|
|
// 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.spec == nil || (s.pick == nil && !s.rotating()) {
|
|
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
|
|
}
|
|
// Scenario F: join the one-heavy-operation-at-a-time gate. A restore-test PULLS a multi-GB
|
|
// archive over the same tunnel an offsite backup PUSHES one; running both saturates the link and
|
|
// drives each toward its timeout, which is how a healthy tier gets recorded as failed. DEFER —
|
|
// never cancel what is already running: a deferred restore-test costs hours of coverage, a
|
|
// cancelled backup costs the backup.
|
|
release, busy, ok := s.inFlight.TryAcquire("restore-test")
|
|
if !ok {
|
|
s.logger.Info("backup: restore-test deferred — a heavy operation is already in flight", "busy", busy)
|
|
return
|
|
}
|
|
defer release()
|
|
|
|
archive, target, err := s.pickForThisRun(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)
|
|
// Rotation credit is given ONLY on success. A failing tier must keep sorting first, or a tier
|
|
// that fails every time would look freshly proven and quietly stop being retried.
|
|
if rt.Pass && s.rtState != nil && target != "" {
|
|
if err := s.rtState.RecordSuccess(target, s.now()); err != nil {
|
|
s.logger.Warn("backup: could not persist the restore-test rotation state", "target", target, "err", err)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// rotating reports whether multi-tier rotation is wired.
|
|
func (s *Scheduler) rotating() bool { return len(s.tiers) > 0 && s.tierPick != nil }
|
|
|
|
// pickForThisRun chooses the tier and its newest archive.
|
|
//
|
|
// OLDEST-FIRST (operator ruling 2026-07-26, Option 1): the tier whose last SUCCESSFUL restore-test
|
|
// is oldest goes first, never-proven first of all. Self-balancing, no config knob, and it naturally
|
|
// prioritises a tier that has never been proven — which on this fleet was the offsite tier, unproven
|
|
// for its entire existence while reporting `applied`.
|
|
//
|
|
// A tier with no archives is SKIPPED, not failed, and the next tier is tried. Skipping to a testable
|
|
// tier is strictly better than burning the whole cadence: a brand-new offsite tier has nothing to
|
|
// restore yet, and that is normal, not broken. It cannot starve the empty tier either — as soon as
|
|
// it has an archive it still sorts first, because it is still the least recently proven.
|
|
//
|
|
// Returns ("", "", nil) when nothing anywhere is testable.
|
|
func (s *Scheduler) pickForThisRun(ctx context.Context) (archive, target string, err error) {
|
|
if !s.rotating() {
|
|
a, perr := s.pick(ctx)
|
|
return a, "", perr // pre-R-85 single-tier path; no rotation credit to record
|
|
}
|
|
order := s.tiers
|
|
if s.rtState != nil {
|
|
order = s.rtState.OldestFirst(s.tiers)
|
|
}
|
|
var firstErr error
|
|
for _, t := range order {
|
|
a, perr := s.tierPick(ctx, t)
|
|
if perr != nil {
|
|
// One tier's storage being unreadable must not block the others.
|
|
s.logger.Warn("backup: restore-test candidate lookup failed for a tier; trying the next",
|
|
"target", t, "err", perr)
|
|
if firstErr == nil {
|
|
firstErr = perr
|
|
}
|
|
continue
|
|
}
|
|
if a == "" {
|
|
s.logger.Debug("backup: restore-test tier has no archive yet; trying the next", "target", t)
|
|
continue
|
|
}
|
|
s.logger.Info("backup: restore-test tier selected (oldest-proven first)", "target", t, "archive", a)
|
|
return a, t, nil
|
|
}
|
|
if firstErr != nil {
|
|
return "", "", firstErr
|
|
}
|
|
return "", "", nil
|
|
}
|