Files
felhom-agent/internal/pbs/live_reporter_test.go
T
admin 1c8a67eece R-106 + R-109: the DR recipe records the resolved namespace and names the backup target (v0.118.0)
Both defects were live on both demo boxes: the recipe said namespace "root" while
storage.cfg said demo-felhom/demo-hp, and it never named which of two content=backup
dir storages holds the local archives.

R-106: the namespace came from the listed snapshot, but PBS omits `ns` per item once
the list is namespace-scoped, so it was always empty and normalised to "root". It now
resolves from the pbs STORAGE (storage.cfg's `namespace`) — the same field vzdump makes
PVE read, so the recipe cannot disagree with the backup.

R-109: backup_target resolves from the primary tier of cfg.Backup.BackupTiers(), the
function the scheduler consults, and carries the mountpoint that separates /mnt/hdd_1
from /var/lib/vz. The resolver reports the tier IN EFFECT (daemon-start config), not
agent.json on disk — a target move rewrites the file and deliberately does not restart.

Unresolvable is recorded as unresolvable: resolved|unknown plus a distinct reason,
never a default, an empty string, or a placeholder.

Needs hub v0.83.0 — AssembleDRRecipe allow-lists top-level keys, so backup_target
would otherwise be stored intact and dropped before any operator saw it.

9 tests, 4 red-proofs (each mutation asserted to have landed). Suite rc=0, 29 ok.
2026-07-30 13:11:08 +02:00

189 lines
7.6 KiB
Go

package pbs
import (
"context"
"errors"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// fakeReporter builds a LiveSnapshotReporter with an injected snapshot-lister seam and a targets
// closure, so no live PBS is needed. listFn is keyed by datastore; targetsErr forces a targets-
// resolution failure. The Target.Client is a throwaway &Client{} the seam never dereferences.
func fakeReporter(store *SnapshotStore, datastores []string, targetsErr error,
listFn func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error)) *LiveSnapshotReporter {
targets := func(ctx context.Context) ([]Target, error) {
if targetsErr != nil {
return nil, targetsErr
}
out := make([]Target, 0, len(datastores))
for _, ds := range datastores {
out = append(out, Target{Datastore: ds, Client: &Client{}})
}
return out, nil
}
r := NewLiveSnapshotReporter(targets, store, 2*time.Second, nil)
r.listSnapshots = func(ctx context.Context, t Target) ([]hub.PBSSnapshot, error) {
return listFn(ctx, t.Datastore)
}
return r
}
func snap(ds, backupID, backupTime string) hub.PBSSnapshot {
return hub.PBSSnapshot{Namespace: "root", BackupType: "ct", BackupID: backupID, BackupTime: backupTime}
}
// T1 (load-bearing): coord present with NO prior verify. A fresh store (verify never ran) + a live
// lister returning 2 snapshots → PBSSnapshots returns both, and feeding them through
// BuildDRRecipeHostHalf yields a non-nil pbs whose latest_snapshot_id is the LATER snapshot.
func TestLiveReporter_CoordPresentWithoutPriorVerify(t *testing.T) {
store := NewSnapshotStore() // empty — simulates a just-restarted daemon, no verify yet
r := fakeReporter(store, []string{"felhom-spike"}, nil,
func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error) {
return []hub.PBSSnapshot{
snap(ds, "9201", "2026-06-13T20:00:00Z"),
snap(ds, "9201", "2026-06-16T17:00:00Z"), // latest
}, nil
})
got := r.PBSSnapshots(context.Background())
if len(got) != 2 {
t.Fatalf("want 2 live snapshots, got %d", len(got))
}
// The whole point: a recipe built from the live read carries the pbs coord.
h := hub.BuildDRRecipeHostHalf(nil,
[]hub.StorageTarget{{Name: "felhom-pbs", Type: hub.StorageTypePBS, Content: "backup"}}, got,
hub.ConfiguredBackupTarget{StorageID: "felhom-pbs", Known: true})
if h.PBS == nil {
t.Fatal("pbs coord absent despite a reachable PBS — the gap this fixes")
}
if h.PBS.RepoID != "felhom-pbs" || h.PBS.Namespace != "root" || h.PBS.LatestSnapshotID != "9201" {
t.Errorf("pbs coord = %+v, want felhom-pbs/root/9201", h.PBS)
}
// COMPANION (pre-fix): the bare SnapshotStore (no live read) with an empty store omits pbs.
bare := NewSnapshotStore()
h2 := hub.BuildDRRecipeHostHalf(nil,
[]hub.StorageTarget{{Name: "felhom-pbs", Type: hub.StorageTypePBS, Content: "backup"}},
bare.PBSSnapshots(context.Background()),
hub.ConfiguredBackupTarget{StorageID: "felhom-pbs", Known: true})
if h2.PBS != nil {
t.Fatal("companion sanity: the bare store should yield NO pbs coord (proves the live read is load-bearing)")
}
}
// T2: live error → last-known-good fallback (and the store is NOT clobbered).
func TestLiveReporter_ErrorFallsBackToLastKnownGood(t *testing.T) {
store := NewSnapshotStore()
store.Record("D", []hub.PBSSnapshot{snap("D", "9001", "2026-06-10T00:00:00Z")})
r := fakeReporter(store, []string{"D"}, nil,
func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error) {
return nil, errors.New("pbs unreachable")
})
got := r.PBSSnapshots(context.Background())
if len(got) != 1 || got[0].BackupID != "9001" {
t.Fatalf("want the last-known-good snapshot on live error, got %+v", got)
}
// The store must still hold the prior set (a failed live read must not wipe it).
if lkg := store.Get("D"); len(lkg) != 1 || lkg[0].BackupID != "9001" {
t.Errorf("live error clobbered the store: %+v", lkg)
}
// COMPANION (pre-fix): without the fallback branch (return empty on error) the result is empty.
noFallback := func(ctx context.Context) []hub.PBSSnapshot {
targets, _ := r.targets(ctx)
out := []hub.PBSSnapshot{}
for _, tg := range targets {
if _, err := r.listSnapshots(ctx, tg); err != nil {
continue // the mutation: drop the LKG-append branch
}
}
return out
}
if len(noFallback(context.Background())) != 0 {
t.Fatal("companion sanity: the no-fallback variant should return empty")
}
}
// T3: a successful response updates the store (last-known-good warm for a later error).
func TestLiveReporter_SuccessUpdatesStore(t *testing.T) {
store := NewSnapshotStore() // empty
r := fakeReporter(store, []string{"D"}, nil,
func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error) {
return []hub.PBSSnapshot{snap("D", "9201", "2026-06-16T00:00:00Z")}, nil
})
_ = r.PBSSnapshots(context.Background())
if lkg := store.Get("D"); len(lkg) != 1 || lkg[0].BackupID != "9201" {
t.Errorf("store not warmed by a successful live read: %+v", lkg)
}
}
// T4: targets-resolution error → the full last-known-good aggregate (not empty).
func TestLiveReporter_TargetsErrorReturnsAggregate(t *testing.T) {
store := NewSnapshotStore()
store.Record("D1", []hub.PBSSnapshot{snap("D1", "1", "2026-06-10T00:00:00Z")})
store.Record("D2", []hub.PBSSnapshot{snap("D2", "2", "2026-06-11T00:00:00Z")})
r := fakeReporter(store, nil, errors.New("ListStorage failed"),
func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error) {
t.Fatal("listSnapshots must not be called when targets fail to resolve")
return nil, nil
})
got := r.PBSSnapshots(context.Background())
if len(got) != 2 {
t.Fatalf("want the 2-snapshot aggregate on a targets-resolution error, got %d (%+v)", len(got), got)
}
}
// T5: bounded. A lister that blocks until ctx is done must return within ~timeout (the child-ctx
// deadline) and fall back, not hang. A short timeout keeps the test fast + deterministic.
func TestLiveReporter_BoundedByTimeout(t *testing.T) {
store := NewSnapshotStore()
store.Record("D", []hub.PBSSnapshot{snap("D", "9001", "2026-06-10T00:00:00Z")})
targets := func(ctx context.Context) ([]Target, error) {
return []Target{{Datastore: "D", Client: &Client{}}}, nil
}
r := NewLiveSnapshotReporter(targets, store, 100*time.Millisecond, nil)
r.listSnapshots = func(ctx context.Context, t Target) ([]hub.PBSSnapshot, error) {
<-ctx.Done() // block until the child-ctx deadline fires
return nil, ctx.Err()
}
done := make(chan []hub.PBSSnapshot, 1)
go func() { done <- r.PBSSnapshots(context.Background()) }()
select {
case got := <-done:
// Fell back to last-known-good rather than hanging.
if len(got) != 1 || got[0].BackupID != "9001" {
t.Errorf("want last-known-good after the deadline, got %+v", got)
}
case <-time.After(2 * time.Second):
t.Fatal("PBSSnapshots hung past the timeout — not bounded by the child ctx")
}
}
// T6: an empty-but-successful response is authoritative — it OVERWRITES a prior non-empty set.
// (Documents the chosen semantics: a real empty datastore must be reflected, not masked by stale LKG.)
func TestLiveReporter_EmptySuccessIsAuthoritative(t *testing.T) {
store := NewSnapshotStore()
store.Record("D", []hub.PBSSnapshot{snap("D", "9001", "2026-06-10T00:00:00Z")})
r := fakeReporter(store, []string{"D"}, nil,
func(ctx context.Context, ds string) ([]hub.PBSSnapshot, error) {
return []hub.PBSSnapshot{}, nil // success, but the datastore is now empty
})
got := r.PBSSnapshots(context.Background())
if len(got) != 0 {
t.Errorf("empty-but-successful should yield an empty result, got %+v", got)
}
if lkg := store.Get("D"); len(lkg) != 0 {
t.Errorf("empty success should overwrite the store to empty, got %+v", lkg)
}
}