1c8a67eece
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.
189 lines
7.6 KiB
Go
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)
|
|
}
|
|
}
|