2d20859858
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.
543 lines
20 KiB
Go
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")
|
|
}
|
|
}
|