fbe113011d
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>
98 lines
4.1 KiB
Go
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
|
|
}
|