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>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user