Files
felhom-controller/controller/internal/backup/offbox_3a_test.go
T
admin 2d20859858 Offsite tier policy engine: mandatory userdata, raw-data quota, restore rework (Task 3a, v0.134.0)
Each toggled app's offsite push = one multi-path restic snapshot (recovery unit + TierOffsite
mandatory userdata via ComputeCaptureSet); legacy/undeployed stay unit-only. Loud capture gaps
(SP-3.4: restic 0.14.0 silently skips missing paths). Quota = stats --mode raw-data (SP-1;
displayed size drops once). Pre-push enlargement gate blocks the userdata enlargement over-quota
(unit-only push continues; EnlargedBlocked; edge-triggered notify). forget --group-by host,tags
on both sites (SP-2). Restore reworked: scratch off the rootfs + headroom gate (F-A1), unit-only
default via --include, size-first full, place-to-live missing-only merge (never --delete).
UI: unit/full-two-step/place actions + per-app blocked note; route POST /backup/offbox/place.
HUB FLAG: offbox_enlarge_blocked event needs hub allowlist for push delivery.
+13 tests; all 10 §10 red-proofs verified. No tier-2/.fab/hub/agent changes.
2026-07-14 22:51:54 +02:00

543 lines
20 KiB
Go

package backup
import (
"context"
"os"
pathpkg "path"
"path/filepath"
"strings"
"sync"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// mandAbs builds the capture-set Abs the code produces: ComputeCaptureSet uses path.Join (slash) —
// the 3-core separator rule — so on the Windows test host the mandatory path is drive + "/rel".
func mandAbs(drive, rel string) string { return pathpkg.Join(drive, rel) }
// offbox3aProvider is a configurable StackDataProvider for the 3a capture-set tests: per-stack HDD
// path + classified binds.
type offbox3aProvider struct {
hdd map[string]string
binds map[string][]ClassifiedBind
has map[string]bool
}
func (p *offbox3aProvider) GetStackComposePath(string) (string, bool) { return "", false }
func (p *offbox3aProvider) ListDeployedStacks() []StackSummary { return nil }
func (p *offbox3aProvider) GetStackHDDMounts(string) []string { return nil }
func (p *offbox3aProvider) GetStackHDDPath(n string) string { return p.hdd[n] }
func (p *offbox3aProvider) GetDockerVolumes(string) []string { return nil }
func (p *offbox3aProvider) StopStack(string) error { return nil }
func (p *offbox3aProvider) StartStack(string) error { return nil }
func (p *offbox3aProvider) RefreshAndIsRunning(string) bool { return false }
func (p *offbox3aProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) { return RecoveryInfo{}, false }
func (p *offbox3aProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
func (p *offbox3aProvider) RecreateStackFromUnit(_, _ string, _ map[string]string) error { return nil }
func (p *offbox3aProvider) GetStackClassifiedBinds(n string) ([]ClassifiedBind, bool) {
return p.binds[n], p.has[n]
}
func mandatoryHDD(rel string) ClassifiedBind {
return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassMandatory}
}
func optionalUserdata(rel string) ClassifiedBind {
return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel, ReadOnly: true}, Class: appbackup.ClassOptional}
}
func excludedHDD(rel string) ClassifiedBind {
return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassExcluded}
}
// classifiedOffboxManager: a configured offbox manager + a classified provider + the drive registered
// as a schedulable storage path (so discoverOffboxUnit finds units on it).
func classifiedOffboxManager(t *testing.T, drive string) (*Manager, *settings.Settings, *offbox3aProvider) {
t.Helper()
m, sett := newOffboxManager(t)
prov := &offbox3aProvider{hdd: map[string]string{}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}}
m.SetStackProvider(prov)
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "USB", Schedulable: true}); err != nil {
t.Fatal(err)
}
return m, sett, prov
}
// mkUnit lays down a discoverable recovery unit for stack on drive.
func mkUnit(t *testing.T, drive, stack string) string {
t.Helper()
u := RecoveryUnitPath(drive, stack)
if err := os.MkdirAll(u, 0o755); err != nil {
t.Fatal(err)
}
return u
}
// captureBackupRunner records the FULL argv of each backup call (keyed by stack tag) + forget argv, and
// answers the probes so RunOffboxBackup completes.
type backupCapture struct {
mu sync.Mutex
byStack map[string][]string
forgets [][]string
backups int
}
func (c *backupCapture) runner() offboxRunner {
c.byStack = map[string][]string{}
return func(_ context.Context, _ []string, args ...string) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
switch {
case contains(args, "cat") && contains(args, "config"):
return []byte(`{"version":2}`), nil
case contains(args, "backup"):
c.backups++
c.byStack[tagOf(args)] = append([]string{}, args...)
return nil, nil
case contains(args, "forget"):
c.forgets = append(c.forgets, append([]string{}, args...))
return nil, nil
case contains(args, "snapshots"):
return []byte(`[]`), nil
case contains(args, "stats"):
return []byte(`{"total_size":123}`), nil
}
return nil, nil
}
}
// --- Scenario A: classified enlarged push (immich shape) ---
func TestOffbox3a_EnlargedPush_MandatoryOnly(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
unit := mkUnit(t, drive, "immich")
if err := os.MkdirAll(filepath.Join(drive, "appdata", "immich"), 0o755); err != nil {
t.Fatal(err)
}
// The optional :ro library exists on disk — so if the tier filter ever leaked it, the stat-filter
// would NOT hide it (this makes the RP-A tier-filter red-proof observable).
if err := os.MkdirAll(filepath.Join(drive, "userdata", "media", "photos"), 0o755); err != nil {
t.Fatal(err)
}
prov.hdd["immich"] = drive
prov.has["immich"] = true
prov.binds["immich"] = []ClassifiedBind{mandatoryHDD("appdata/immich"), optionalUserdata("media/photos")}
_ = sett.SetAppOffbox("immich", true)
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
if cap.backups != 1 {
t.Fatalf("exactly ONE snapshot per app, got %d backup calls", cap.backups)
}
args := cap.byStack["immich"]
wantMandatory := mandAbs(drive, "appdata/immich")
if !contains(args, unit) {
t.Errorf("backup argv missing the unit path %q: %v", unit, args)
}
if !contains(args, wantMandatory) {
t.Errorf("backup argv missing the mandatory userdata path %q: %v", wantMandatory, args)
}
if contains(args, mandAbs(drive, "userdata/media/photos")) {
t.Errorf("OPTIONAL :ro path must NOT ship offsite: %v", args)
}
}
// --- Scenario B: legacy / undeployed stay unit-only ---
func TestOffbox3a_LegacyUnitOnly(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
unit := mkUnit(t, drive, "sonarr")
if err := os.MkdirAll(filepath.Join(drive, "appdata", "sonarr"), 0o755); err != nil {
t.Fatal(err)
}
prov.hdd["sonarr"] = drive
prov.has["sonarr"] = false // block REJECTED / absent → legacy (binds present but no class semantics)
prov.binds["sonarr"] = []ClassifiedBind{mandatoryHDD("appdata/sonarr")}
_ = sett.SetAppOffbox("sonarr", true)
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
args := cap.byStack["sonarr"]
// unit-only: exactly the base shape, last arg is the unit, no extra resolved paths.
if args[len(args)-1] != unit {
t.Errorf("legacy app argv must END at the unit (no resolved paths), got %v", args)
}
for _, a := range args {
if strings.Contains(a, "appdata") || strings.Contains(a, "userdata") {
t.Errorf("legacy app resolved a bind into offsite argv (SQ5 regression): %v", args)
}
}
}
func TestOffbox3a_UndeployedUnitOnlyWithWarning(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
_ = mkUnit(t, drive, "immich")
prov.hdd["immich"] = "" // undeployed → no live HDD_PATH
prov.has["immich"] = true
prov.binds["immich"] = []ClassifiedBind{mandatoryHDD("appdata/immich")}
_ = sett.SetAppOffbox("immich", true)
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
args := cap.byStack["immich"]
if strings.Contains(strings.Join(args, " "), "appdata") {
t.Errorf("undeployed app must push unit-only: %v", args)
}
if w := sett.GetOffboxTarget().LastWarning; !strings.Contains(w, "nincs telepítve") {
t.Errorf("undeployed warning missing from LastWarning: %q", w)
}
}
// --- Scenario C: pre-push enlargement gate ---
func TestOffbox3a_EnlargementGateBlocks(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
for _, app := range []string{"immich", "small"} {
_ = mkUnit(t, drive, app)
if err := os.MkdirAll(filepath.Join(drive, "appdata", app), 0o755); err != nil {
t.Fatal(err)
}
prov.hdd[app] = drive
prov.has[app] = true
prov.binds[app] = []ClassifiedBind{mandatoryHDD("appdata/" + app)}
_ = sett.SetAppOffbox(app, true)
}
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 50; o.RepoSizeBytes = 20 << 30 })
// immich's mandatory set is 40 GiB (20+40 ≥ 50 → blocked); small's is 1 GiB (20+1 < 50 → fits).
m.SetOffboxSizer(func(p string) int64 {
if strings.Contains(p, "immich") {
return 40 << 30
}
return 1 << 30
})
var noteMu sync.Mutex
var notes []string
m.SetOffboxEnlargeBlockedNotifier(func(stack string, _ int64, usedGB, quotaGB int) {
noteMu.Lock()
defer noteMu.Unlock()
notes = append(notes, stack)
if usedGB != 20 || quotaGB != 50 {
t.Errorf("notifier numbers wrong: used=%d quota=%d", usedGB, quotaGB)
}
})
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run must be OK (a blocked enlargement is not a run failure): %v", err)
}
// immich → unit-only; small → enlarged.
if strings.Contains(strings.Join(cap.byStack["immich"], " "), "appdata") {
t.Errorf("blocked immich must be unit-only: %v", cap.byStack["immich"])
}
if !contains(cap.byStack["small"], mandAbs(drive, "appdata/small")) {
t.Errorf("fitting 'small' must still push enlarged: %v", cap.byStack["small"])
}
tgt := sett.GetOffboxTarget()
if len(tgt.EnlargedBlocked) != 1 || tgt.EnlargedBlocked[0] != "immich" {
t.Errorf("EnlargedBlocked = %v, want [immich]", tgt.EnlargedBlocked)
}
if tgt.LastStatus != "ok" {
t.Errorf("run status = %q, want ok", tgt.LastStatus)
}
if !strings.Contains(tgt.LastWarning, "tárhelykeret miatt") || !strings.Contains(tgt.LastWarning, "immich") {
t.Errorf("blocked LastWarning missing: %q", tgt.LastWarning)
}
if len(notes) != 1 || notes[0] != "immich" {
t.Errorf("notifier must fire ONCE for immich, got %v", notes)
}
// EnlargedBlocked clears on a subsequent run where nothing is blocked.
m.SetOffboxSizer(func(string) int64 { return 1 << 30 }) // now immich fits too
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatal(err)
}
if b := sett.GetOffboxTarget().EnlargedBlocked; len(b) != 0 {
t.Errorf("EnlargedBlocked must clear when nothing is blocked, got %v", b)
}
}
// --- Scenario D: capture gaps are loud (SP-3.4) ---
func TestOffbox3a_CaptureGapsAreLoud(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
_ = mkUnit(t, drive, "app")
if err := os.MkdirAll(filepath.Join(drive, "appdata", "good"), 0o755); err != nil {
t.Fatal(err)
}
prov.hdd["app"] = drive
prov.has["app"] = true
prov.binds["app"] = []ClassifiedBind{
mandatoryHDD("appdata/good"), // exists → captured
mandatoryHDD("../evil"), // D1: traversal → Skipped
mandatoryHDD("appdata/ghost"), // D2: passes guards but absent on disk → stat-filtered
}
_ = sett.SetAppOffbox("app", true)
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
args := cap.byStack["app"]
joined := strings.Join(args, " ")
if !contains(args, mandAbs(drive, "appdata/good")) {
t.Errorf("the valid mandatory path must still push: %v", args)
}
if strings.Contains(joined, "evil") {
t.Errorf("traversal path escaped into argv: %v", args)
}
if strings.Contains(joined, "ghost") {
t.Errorf("stat-missing mandatory path must NOT be in argv (SP-3.4 silent-skip): %v", args)
}
if w := sett.GetOffboxTarget().LastWarning; !strings.Contains(w, "nem kerültek a távoli mentésbe") {
t.Errorf("capture-gap warning missing from LastWarning: %q", w)
}
}
// --- §8 all-excluded row (radarr shape): unit-only, NO warning ---
func TestOffbox3a_AllExcludedUnitOnlyNoWarning(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
unit := mkUnit(t, drive, "radarr")
prov.hdd["radarr"] = drive
prov.has["radarr"] = true
prov.binds["radarr"] = []ClassifiedBind{excludedHDD("appdata/radarr"), excludedHDD("downloads")}
_ = sett.SetAppOffbox("radarr", true)
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
if args := cap.byStack["radarr"]; args[len(args)-1] != unit {
t.Errorf("all-excluded app must be unit-only: %v", args)
}
if w := sett.GetOffboxTarget().LastWarning; strings.Contains(w, "nem kerültek") {
t.Errorf("all-excluded is correct, NOT a gap — no warning expected, got %q", w)
}
}
// --- Scenario E: raw-data stats mode ---
func TestOffbox3a_StatsRawDataMode(t *testing.T) {
m, sett := newOffboxManager(t)
var statsArgs []string
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
switch {
case contains(args, "snapshots"):
return []byte(`[{"id":"a"}]`), nil
case contains(args, "stats"):
statsArgs = append([]string{}, args...)
return []byte(`{"total_size":987654321}`), nil
}
return nil, nil
})
base, env := m.offboxBaseArgs(sett.GetOffboxTarget())
m.offboxRecordStats(context.Background(), base, env)
if !contains(statsArgs, "--mode") || valAfter(statsArgs, "--mode") != "raw-data" {
t.Fatalf("stats must run in raw-data mode, got %v", statsArgs)
}
if got := sett.GetOffboxTarget().RepoSizeBytes; got != 987654321 {
t.Errorf("RepoSizeBytes = %d, want 987654321 (parsed from raw-data total_size)", got)
}
}
// --- Scenario F: both forget call sites carry --group-by host,tags ---
func TestOffbox3a_ForgetGrouping_MainRun(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
_ = mkUnit(t, drive, "app")
prov.has["app"] = false
_ = sett.SetAppOffbox("app", true)
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatal(err)
}
if len(cap.forgets) != 1 {
t.Fatalf("expected one forget call, got %d", len(cap.forgets))
}
if valAfter(cap.forgets[0], "--group-by") != "host,tags" {
t.Errorf("main-run forget missing --group-by host,tags: %v", cap.forgets[0])
}
}
func TestOffbox3a_ForgetGrouping_OverQuotaPrune(t *testing.T) {
m, sett := newOffboxManager(t)
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 50; o.RepoSizeBytes = 51 << 30 })
var forgetArgs []string
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
switch {
case contains(args, "cat") && contains(args, "config"):
return []byte(`{}`), nil
case contains(args, "forget"):
forgetArgs = append([]string{}, args...)
case contains(args, "snapshots"):
return []byte(`[]`), nil
case contains(args, "stats"):
return []byte(`{"total_size":1}`), nil
}
return nil, nil
})
_ = m.RunOffboxBackup(context.Background()) // over-quota → prune-only path
if valAfter(forgetArgs, "--group-by") != "host,tags" {
t.Errorf("over-quota prune forget missing --group-by host,tags: %v", forgetArgs)
}
}
// --- Scenario E-restore: unit-only restore argv (ID-first + --include) + scratch OFF the rootfs ---
func TestOffbox3a_UnitOnlyRestoreArgv(t *testing.T) {
drive := t.TempDir()
m, _, prov := classifiedOffboxManager(t, drive)
prov.hdd["immich"] = drive
m.SetOffboxFreeFn(func(string) int64 { return 100 << 30 }) // plenty
unitPath := filepath.ToSlash(filepath.Join(drive, "backups", "primary", "immich"))
var restoreArgs []string
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
switch {
case contains(args, "snapshots"):
return []byte(`[{"short_id":"deadbeef","time":"2026-07-14T00:00:00Z","paths":["` + filepath.ToSlash(filepath.Join(drive, "appdata", "immich")) + `","` + unitPath + `"]}]`), nil
case contains(args, "restore"):
restoreArgs = append([]string{}, args...)
}
return nil, nil
})
if err := m.RestoreOffboxScratch(context.Background(), "immich", false); err != nil {
t.Fatalf("unit-only restore: %v", err)
}
if valAfter(restoreArgs, "restore") != "deadbeef" {
t.Errorf("restore must be ID-first (deadbeef): %v", restoreArgs)
}
if valAfter(restoreArgs, "--include") != unitPath {
t.Errorf("unit-only restore must --include the absolute unit path %q: %v", unitPath, restoreArgs)
}
target := valAfter(restoreArgs, "--target")
if !strings.HasPrefix(target, drive) || strings.Contains(target, m.cfg.Paths.DataDir) {
t.Errorf("scratch target must be on the data drive, never DataDir: %q", target)
}
}
// full restore refuses fail-closed when the snapshot size is unknown (no restore call made).
func TestOffbox3a_FullRestoreRefusesOnSizeUnknown(t *testing.T) {
drive := t.TempDir()
m, _, prov := classifiedOffboxManager(t, drive)
prov.hdd["immich"] = drive
m.SetOffboxFreeFn(func(string) int64 { return 100 << 30 })
unitPath := filepath.Join(drive, "backups", "primary", "immich")
restoreCalled := false
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
switch {
case contains(args, "snapshots"):
return []byte(`[{"short_id":"a","time":"2026-07-14T00:00:00Z","paths":["` + filepath.ToSlash(unitPath) + `"]}]`), nil
case contains(args, "stats"):
return nil, context.DeadlineExceeded // size lookup fails → unknown
case contains(args, "restore"):
restoreCalled = true
}
return nil, nil
})
err := m.RestoreOffboxScratch(context.Background(), "immich", true)
if err == nil || !strings.Contains(err.Error(), "nem állapítható meg") {
t.Fatalf("full restore must refuse fail-closed on unknown size, got err=%v", err)
}
if restoreCalled {
t.Error("no restic restore call may run when the size is unknown")
}
}
// old rootfs scratch is cleaned up on a new restore.
func TestOffbox3a_LegacyRootfsScratchCleanup(t *testing.T) {
drive := t.TempDir()
m, _, prov := classifiedOffboxManager(t, drive)
prov.hdd["immich"] = drive
m.SetOffboxFreeFn(func(string) int64 { return 100 << 30 })
legacy := filepath.Join(m.cfg.Paths.DataDir, "offbox-restore", "immich")
if err := os.MkdirAll(legacy, 0o755); err != nil {
t.Fatal(err)
}
unitPath := filepath.Join(drive, "backups", "primary", "immich")
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
if contains(args, "snapshots") {
return []byte(`[{"short_id":"a","time":"2026-07-14T00:00:00Z","paths":["` + filepath.ToSlash(unitPath) + `"]}]`), nil
}
return nil, nil
})
if err := m.RestoreOffboxScratch(context.Background(), "immich", false); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(legacy); !os.IsNotExist(err) {
t.Errorf("legacy rootfs scratch %s must be removed, stat err=%v", legacy, err)
}
}
// --- Scenario G: place-to-live mapping (pure) + the wrong cases ---
func TestMapOffsiteRestorePaths(t *testing.T) {
old := "/old/ns"
newNs := "/new/ns"
scratch := "/scratch"
snap := []string{
old + "/backups/primary/app",
old + "/appdata/app",
old + "/userdata/media/x",
}
got, err := mapOffsiteRestorePaths(snap, "app", scratch, newNs)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d placements, want 3: %+v", len(got), got)
}
byDst := map[string]placement{}
for _, pl := range got {
byDst[pl.dst] = pl
}
// anchor derived by trimming backups/primary/app off the unit path → oldNs; dst = newNs/<rel>,
// src = scratch/<abs-source> (SP-3.1). Built with filepath.Join to match the code (OS separators).
check := func(snapPath, rel string, isUnit bool) {
dst := filepath.Join(newNs, rel)
pl, ok := byDst[dst]
if !ok {
t.Errorf("missing placement for dst %q", dst)
return
}
if pl.src != filepath.Join(scratch, snapPath) {
t.Errorf("src for %q = %q, want %q", snapPath, pl.src, filepath.Join(scratch, snapPath))
}
if pl.isUnit != isUnit {
t.Errorf("isUnit for %q = %v, want %v", snapPath, pl.isUnit, isUnit)
}
}
check(old+"/backups/primary/app", "backups/primary/app", true)
check(old+"/appdata/app", "appdata/app", false)
check(old+"/userdata/media/x", "userdata/media/x", false)
// Wrong cases — each REFUSES the whole placement.
if _, err := mapOffsiteRestorePaths([]string{old + "/appdata/app"}, "app", scratch, newNs); err == nil {
t.Error("no unit path → must refuse")
}
if _, err := mapOffsiteRestorePaths([]string{old + "/backups/primary/app", "/elsewhere/x"}, "app", scratch, newNs); err == nil {
t.Error("a path outside the namespace → must refuse")
}
if _, err := mapOffsiteRestorePaths([]string{old + "/backups/primary/app", old + "/backups/secondary/y"}, "app", scratch, newNs); err == nil {
t.Error("a non-unit path in the reserved backups/ zone → must refuse")
}
}