d4a7a5bad3
The probe logged only on failure, so a healthy one was silent: "no auth_failed" was indistinguishable from "never probed", and the leg could not be demonstrated as running. That is exactly how v0.91.0 shipped it inert unnoticed.
157 lines
7.5 KiB
Go
157 lines
7.5 KiB
Go
package pbs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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)
|
|
|
|
// probeAuth is the R-39 credential probe seam (default = (*Client).ProbeAuth). Overridable so the
|
|
// auth-honesty path is testable with no PBS.
|
|
probeAuth func(ctx context.Context, t Target) error
|
|
// authSink receives every probe result. nil = nobody is listening (the probe is then skipped
|
|
// entirely — no point paying for a request nothing consumes).
|
|
authSink AuthSink
|
|
}
|
|
|
|
// AuthSink receives the result of each per-storage credential probe (R-39 leg c).
|
|
//
|
|
// It exists so the DR bridge can turn a 401 into a LOUD `auth_failed` state instead of the Warn-and-
|
|
// skip that made an applied-but-dead tier invisible. Deliberately a plain interface taking a bool
|
|
// rather than the error: the consumer (internal/pbsdr) must not have to import this package just to
|
|
// test a sentinel.
|
|
type AuthSink interface {
|
|
// NoteAuthResult reports one storage's credential health. unauthorized=true means PBS REJECTED
|
|
// the credential (401) — terminal until it is replaced. A transport error is unauthorized=false
|
|
// with a non-empty detail: unknown, not dead, and never a reason to re-key.
|
|
NoteAuthResult(storageID string, unauthorized bool, detail string)
|
|
}
|
|
|
|
// SetAuthSink wires the credential-probe consumer. Without it the reporter does not probe at all.
|
|
func (r *LiveSnapshotReporter) SetAuthSink(s AuthSink) { r.authSink = s }
|
|
|
|
// 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,
|
|
probeAuth: func(ctx context.Context, t Target) error { return t.Client.ProbeAuth(ctx) },
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// probeAuthAndReport runs the credential probe for one target and forwards the verdict to the sink.
|
|
// No sink → no probe (nothing would consume it). Never fails the collect: a report must still go out.
|
|
func (r *LiveSnapshotReporter) probeAuthAndReport(ctx context.Context, t Target) {
|
|
if r.authSink == nil || r.probeAuth == nil {
|
|
return
|
|
}
|
|
err := r.probeAuth(ctx, t)
|
|
switch {
|
|
case err == nil:
|
|
// Logged even on success: a safety mechanism that is silent when healthy cannot be shown to be
|
|
// RUNNING, and "no auth_failed" is indistinguishable from "never probed". Debug level, so it
|
|
// costs nothing in normal operation but is one log-level away when it matters.
|
|
r.log.Debug("pbs: credential probe OK", "storage", t.StorageID, "datastore", t.Datastore)
|
|
r.authSink.NoteAuthResult(t.StorageID, false, "")
|
|
case errors.Is(err, ErrUnauthorized):
|
|
// The one case that is TERMINAL and actionable: the credential is rejected, not the network.
|
|
r.log.Error("pbs: the DR endpoint REJECTED this box's credential (401) — the storage entry is "+
|
|
"pinned to a superseded secret and every backup/restore against it will fail",
|
|
"storage", t.StorageID, "datastore", t.Datastore)
|
|
r.authSink.NoteAuthResult(t.StorageID, true, "PBS rejected the stored credential (401)")
|
|
default:
|
|
// Unreachable/timeout/TLS — UNKNOWN, not dead. Reporting this as unauthorized would re-key a
|
|
// perfectly good credential on every network blip.
|
|
r.log.Debug("pbs: credential probe inconclusive (not a rejection)", "storage", t.StorageID, "err", err)
|
|
r.authSink.NoteAuthResult(t.StorageID, false, err.Error())
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// R-39 leg (c): prove the credential BEFORE interpreting anything else about this datastore.
|
|
// A snapshot list that fails with 401 used to look identical to "PBS is busy" — which is how
|
|
// an applied-and-dead tier stayed green. The probe runs on this 15-minute collect path (not
|
|
// the 6 h verify cadence) because that is how fast the hub can react.
|
|
r.probeAuthAndReport(childCtx, t)
|
|
|
|
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
|
|
}
|