Files
felhom-agent/internal/pbs/live_reporter.go
T
admin fbe113011d v0.39.0 — DR-recipe completion: live PBS coord + drop role/restic_repo_coord from v1 drive shape
Live PBS coord: new internal/pbs/live_reporter.go (LiveSnapshotReporter implements
hub.PBSReporter via the cheap Client.Snapshots() list with last-known-good fallback,
bounded by an 8s timeout, list-only — never triggers a verify). Closes the gap where
the recipe's pbs block was omitted whenever the verify-loop SnapshotStore was empty
(one-shot collect + the first ~6h after a daemon restart). SnapshotStore.Get added
(per-datastore LKG). Wired into the collector in both runDaemon and runSelftestHub;
the verify loop keeps Recording into the SAME shared store via one hoisted pbsTargets.

v1 host-half drive shape: dropped drives[].role (hub/operator-owned manifest concept,
not host-derivable) and drives[].restic_repo_coord (named a backup tier that doesn't
exist). Drive shape is now {durable_id, mount_path, intent, fs_type?, total_bytes}.
Hub reads drives as json.RawMessage → no hub struct change; goldens re-pinned
byte-identical (agent + hub copies).

Tests: live_reporter_test.go (T1 load-bearing coord-without-verify + T2..T6),
TestDRRecipeHostHalf_V1DriveShape; each companion demonstrated to fail pre-fix then
reverted. go build/vet/test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:25:09 +02:00

98 lines
4.1 KiB
Go

package pbs
import (
"context"
"log/slog"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// DefaultLiveSnapshotTimeout bounds the per-collect live PBS reads so a slow/hung PBS can never
// stall the host-report (and thus the heartbeat). A few seconds is ample for the cheap list GET on
// the LAN; on overrun the reporter falls back to last-known-good rather than blocking.
const DefaultLiveSnapshotTimeout = 8 * time.Second
// LiveSnapshotReporter resolves the PBS snapshot inventory LIVE on each collect (the cheap
// Snapshots() list), so the host-report — and the DR recipe's pbs coord derived from it — is present
// whenever PBS is reachable, INDEPENDENT of the 6 h verify cadence. This closes the gap where a
// one-shot collect (selftest=hub) and the first window of every daemon after a restart saw the
// verify-loop's SnapshotStore still empty (→ no pbs coord, the restore SOURCE missing).
//
// On a per-datastore live error/timeout it falls back to the store's last-known-good; a successful
// (even empty) response is authoritative and updates the store. If targets cannot be resolved at all
// it returns the store's full aggregate. It shares the same *SnapshotStore the verify loop Records
// into, so the two keep each other's last-known-good warm. It NEVER triggers a verify — list only.
type LiveSnapshotReporter struct {
targets Targets // pbsTargetsFromPVE(...) closure — re-resolved each collect
store *SnapshotStore // shared last-known-good cache (verify loop writes it too)
timeout time.Duration // per-collect bound on the live reads
log *slog.Logger
// listSnapshots is the production→PBS seam, overridable in tests so no live PBS is needed.
// Default = liveListSnapshots (one Snapshots() GET, converted via Snapshot.ToHub()).
listSnapshots func(ctx context.Context, t Target) ([]hub.PBSSnapshot, error)
}
// NewLiveSnapshotReporter builds a live reporter sharing store with the verify loop. A zero timeout
// falls back to DefaultLiveSnapshotTimeout; a nil logger to slog.Default.
func NewLiveSnapshotReporter(targets Targets, store *SnapshotStore, timeout time.Duration, log *slog.Logger) *LiveSnapshotReporter {
if timeout <= 0 {
timeout = DefaultLiveSnapshotTimeout
}
if log == nil {
log = slog.Default()
}
return &LiveSnapshotReporter{
targets: targets,
store: store,
timeout: timeout,
log: log,
listSnapshots: liveListSnapshots,
}
}
// liveListSnapshots does ONE cheap snapshot list for a target and converts each record to the hub
// wire shape (reusing Snapshot.ToHub — RFC3339 backup_time, verify_state). List only; no verify.
func liveListSnapshots(ctx context.Context, t Target) ([]hub.PBSSnapshot, error) {
snaps, err := t.Client.Snapshots(ctx, t.Datastore)
if err != nil {
return nil, err
}
out := make([]hub.PBSSnapshot, 0, len(snaps))
for _, s := range snaps {
out = append(out, s.ToHub())
}
return out, nil
}
// PBSSnapshots implements hub.PBSReporter with a single bounded, live pass (last-known-good
// fallback). Non-nil result so it marshals as [].
func (r *LiveSnapshotReporter) PBSSnapshots(ctx context.Context) []hub.PBSSnapshot {
childCtx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
targets, err := r.targets(childCtx)
if err != nil {
// Cannot even enumerate datastores → fall back to the full last-known-good aggregate.
r.log.Debug("pbs: live targets resolution failed; using last-known-good aggregate", "err", err)
return r.store.PBSSnapshots(ctx)
}
out := []hub.PBSSnapshot{}
for _, t := range targets {
snaps, err := r.listSnapshots(childCtx, t)
if err != nil {
// Per-datastore live failure → that datastore's last-known-good (does NOT clobber it).
r.log.Debug("pbs: live list failed; using last-known-good", "datastore", t.Datastore, "err", err)
out = append(out, r.store.Get(t.Datastore)...)
continue
}
// A successful response (INCLUDING empty) is authoritative — record it as the new
// last-known-good so a later error reuses fresh truth, not a stale set.
r.store.Record(t.Datastore, snaps)
out = append(out, snaps...)
}
return out
}