c6b69d888e
gates / gates (push) Successful in 21s
THE VERDICT. The R-203 block already said "a warning beside a success is read as a success" and applied it to ONE of the two shapes it describes: an app missing a declared mandatory FOLDER made the run incomplete, while an app skipped ENTIRELY still reported ok. Both do now. Which skips count, decided by measurement: selected+deployed with no recovery unit YES; selected but NOT deployed no (named, with what to do — a box left amber by an app somebody removed is a status nobody reads); disconnected/decommissioned drive no (own signal); nothing selected no. LastSuccess and SnapshotCount still record what WAS captured. THE FILED MECHANISM WAS NOT THE MEASURED CAUSE, and saying so is the point. §3 stated that toggling an app on leaves it without a bundle so the first run skips it. Measured on demo-hp: the run's own pre-dump phase calls captureAllRecoveryUnits for every DEPLOYED stack, through admitApp, before the push — a unit moved aside was RECREATED and the run reported ok. That state does not survive a run. What actually produced the 2026-08-06 sequence: the manual run was dropped by the single-flight while an earlier run was still going. runOffboxBackup returned nil, the handler had already answered "A tavoli mentes elindult", and the card then showed the PREVIOUS run's green verdict — read as covering the app just selected. The decision is now taken synchronously in the handler and a dropped request says so. The nightly path still returns nil on purpose: nobody asked, and it retries. §7.3 measured before deciding: CaptureRecoveryUnit writes a few KB of compose + manifest, only ENUMERATES dumps rather than creating them, is idempotent and does NOT stop the app — and already runs inside the off-site run. So there is no wait to remove for a deployed app and NOTHING was built. 28 packages ok, 9/9 gates. Four red-proofs, each asserted to have applied. Fixture note: the shared provider's ListDeployedStacks returned nil, so Scenario A first passed for the wrong reason; fixed with an opt-in deployed set that defaults to nil.
562 lines
21 KiB
Go
562 lines
21 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
|
|
// deployed is OPT-IN and defaults to nil, so every existing fixture keeps ListDeployedStacks()
|
|
// returning nil and nothing about their behaviour moves. R-234's classification is the only
|
|
// thing that needs a real deployed set.
|
|
deployed map[string]bool
|
|
}
|
|
|
|
func (p *offbox3aProvider) GetStackComposePath(string) (string, bool) { return "", false }
|
|
func (p *offbox3aProvider) ListDeployedStacks() []StackSummary {
|
|
if len(p.deployed) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]StackSummary, 0, len(p.deployed))
|
|
for n := range p.deployed {
|
|
out = append(out, StackSummary{Name: n})
|
|
}
|
|
return out
|
|
}
|
|
func (p *offbox3aProvider) GetStackHDDMounts(string) []string { return nil }
|
|
func (p *offbox3aProvider) GetStackHDDPath(n string) string { return p.hdd[n] }
|
|
func (p *offbox3aProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
|
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) RecreateStackDefinitionFromUnit(_, _ string, _ map[string]string) error {
|
|
return nil
|
|
}
|
|
func (p *offbox3aProvider) StartStackServices(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")
|
|
}
|
|
}
|