package pbs import ( "context" "log/slog" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // DefaultVerifyCadence is the verify maintenance interval — more frequent than the full // self-restore-test, because it's the cheap, key-free, ciphertext-level integrity check (§8). const DefaultVerifyCadence = 6 * time.Hour // Target is one PBS datastore to verify, with its client. type Target struct { Datastore string Client *Client } // Targets resolves the current set of PBS datastores to verify (re-derived each cycle from // the PVE storage config — wired in main.go so pbs stays decoupled from how clients are built). type Targets func(ctx context.Context) ([]Target, error) // VerifyLoop is the verify maintenance loop (slice 6 Phase B). It runs on its OWN cadence and // is a reporting/maintenance task like the slice-5 watchdog — it does NOT go through the // reconcile gate/journal (it mutates no guest). Each cycle, per datastore: trigger a verify → // poll the task → re-list snapshots → record the per-snapshot verify-state for the report. type VerifyLoop struct { targets Targets store *SnapshotStore cadence time.Duration logger *slog.Logger } // VerifyLoopOptions configures a VerifyLoop. type VerifyLoopOptions struct { Targets Targets Store *SnapshotStore Cadence time.Duration // 0 → default 6h; negative → disabled Logger *slog.Logger } // NewVerifyLoop builds a VerifyLoop. func NewVerifyLoop(opts VerifyLoopOptions) *VerifyLoop { logger := opts.Logger if logger == nil { logger = slog.Default() } cadence := opts.Cadence if cadence == 0 { cadence = DefaultVerifyCadence } return &VerifyLoop{targets: opts.Targets, store: opts.Store, cadence: cadence, logger: logger} } // Run verifies on the cadence until ctx is cancelled. It does an immediate first pass (so a // freshly-started agent reports snapshot inventory + verify-state promptly), then on each // tick. A negative cadence (or nil targets/store) disables it. Returns nil on cancellation. func (l *VerifyLoop) Run(ctx context.Context) error { if l.cadence < 0 || l.targets == nil || l.store == nil { l.logger.Info("pbs: verify loop disabled") <-ctx.Done() return nil } l.logger.Info("pbs: verify loop starting", "cadence", l.cadence) l.tick(ctx) // immediate inventory + verify t := time.NewTicker(l.cadence) defer t.Stop() for { select { case <-ctx.Done(): l.logger.Info("pbs: verify loop shutting down", "reason", ctx.Err()) return nil case <-t.C: l.tick(ctx) } } } // RunOnce performs a single synchronous verify+list pass over all targets (used by the // selftest harness and the live runbook). Same work as one cadence tick. func (l *VerifyLoop) RunOnce(ctx context.Context) { l.tick(ctx) } // tick verifies + re-lists each target datastore once. Deterministic enough to drive directly // in tests. A per-target error is logged and skipped (other datastores still report). func (l *VerifyLoop) tick(ctx context.Context) { targets, err := l.targets(ctx) if err != nil { l.logger.Warn("pbs: verify loop could not resolve targets; skipping", "err", err) return } for _, t := range targets { l.verifyOne(ctx, t) } } // verifyOne triggers a verify, waits for it, then re-lists + records the snapshots' state. func (l *VerifyLoop) verifyOne(ctx context.Context, t Target) { if upid, err := t.Client.Verify(ctx, t.Datastore); err != nil { // Verify-trigger failure is non-fatal: still re-list so we report current state. l.logger.Warn("pbs: verify trigger failed; reporting current snapshot state", "datastore", t.Datastore, "err", err) } else if err := t.Client.WaitVerify(ctx, upid, 2*time.Second, 30*time.Minute); err != nil { l.logger.Warn("pbs: verify task wait failed; reporting current snapshot state", "datastore", t.Datastore, "err", err) } snaps, err := t.Client.Snapshots(ctx, t.Datastore) if err != nil { l.logger.Warn("pbs: snapshot list failed", "datastore", t.Datastore, "err", err) return } out := make([]hub.PBSSnapshot, 0, len(snaps)) failed := 0 for _, s := range snaps { h := s.ToHub() if h.VerifyState == VerifyFailed { failed++ } out = append(out, h) } l.store.Record(t.Datastore, out) if failed > 0 { l.logger.Error("pbs: datastore has FAILED-verify snapshots", "datastore", t.Datastore, "failed", failed, "total", len(out)) } else { l.logger.Info("pbs: verify cycle complete", "datastore", t.Datastore, "snapshots", len(out)) } }