68f0e0cf5c
The controller assumed an app's HDD appdata dir is always appdata/<stackName>.
paperless-ngx writes appdata/paperless (stack paperless-ngx), so every consumer
keying by stack name silently missed it via a stat-and-skip. One canonical
resolver appbackup.AppDataDirNames derives the real dir name(s) from the app's
compose ${HDD_PATH} binds; all consumers use it.
- F-S2 (tier-2): RunTier2 mirrors the resolved appdata/<name> (paperless docs
got NO tier-2 copy before). Tier2Info size + RestoreTier2Files live dir use it.
WARN when a declared appdata dir is absent. New tier2Mirror seam.
- F-S3 (migrate, NEW): all six per-app appdata legs (collision/size/copy/verify/
cleanup/skip-set) now loop resolved names. scope="app" migration of paperless
previously copied nothing and left an empty media dir (scope="all" was saved by
the merge walk). WARN on missing declared dir in the copy leg.
- Multi-dir (N>1) refusal: tier-2 backup/info/restore refuse loudly (Hungarian);
migrate supports N. No catalog app hits it today; lifted by Task 3.
- Display: storage page sums resolved dirs.
- Truth repair: the v0.130.0 "tier-2 copies the namespace wholesale" claim is
false; corrected in CHANGELOG + main.go export-adapter comment.
+9 tests; red-proofs RP-1..RP-5 all confirmed. Controller-only, no agent/hub
coupling. Task 1 of the backup-classification-redesign arc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
216 lines
8.4 KiB
Go
216 lines
8.4 KiB
Go
package backup
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// newRunTier2Manager builds a Manager wired to a fake provider, a real temp-dir source drive holding
|
|
// a recovery unit + an appdata/<appDataName> dir (created iff appDataName != ""), and a (non-existent)
|
|
// schedulable off-drive target so selectTier2Target resolves a real target without touching the disk
|
|
// (SamePhysicalDevice returns false for an unstattable path on both Linux and Windows). Compose
|
|
// mounts default to appdata/<appDataName>/media + /export; callers override fake.mounts for the
|
|
// legacy/multi-dir shapes. The tier2Mirror seam captures the (src,dst) of each leg.
|
|
func newRunTier2Manager(t *testing.T, stack, appDataName string) (m *Manager, src string, captured *[][2]string) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
src = filepath.Join(tmp, "usb") // the source drive == HDD_PATH == namespace root (Model A)
|
|
sysPath := filepath.Join(tmp, "sys")
|
|
target := filepath.Join(tmp, "off-drive-target") // never created → treated as a different device
|
|
|
|
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: target, Label: "off", Schedulable: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A recovery unit (gates RunTier2) + the resolved appdata dir, both with real bytes.
|
|
mustWrite(t, filepath.Join(RecoveryUnitPath(src, stack), "manifest.json"), "{}")
|
|
var mounts []string
|
|
if appDataName != "" {
|
|
mustWrite(t, filepath.Join(AppDataDir(src, appDataName), "media", "a.jpg"), "JPEGDATA")
|
|
mounts = []string{
|
|
filepath.Join(src, "appdata", appDataName, "media"),
|
|
filepath.Join(src, "appdata", appDataName, "export"),
|
|
}
|
|
}
|
|
|
|
fake := &t2rFakeProvider{hdd: src, mounts: mounts}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.SystemDataPath = sysPath
|
|
m = NewManager(cfg, sett, log.New(io.Discard, "", 0))
|
|
m.stackProvider = fake
|
|
m.systemDataPath = sysPath
|
|
|
|
pairs := &[][2]string{}
|
|
m.tier2Mirror = func(s, d string) error {
|
|
*pairs = append(*pairs, [2]string{s, d})
|
|
return nil
|
|
}
|
|
return m, src, pairs
|
|
}
|
|
|
|
// TestRunTier2_PaperlessShape (Scenario A / RP-2): the appdata leg is mirrored from the REAL
|
|
// compose-derived dir (appdata/paperless), and the recorded size includes its bytes. Companion
|
|
// RP-2: reverting the L146 site to AppDataDir(nsRoot, stackName) makes the appdata leg mirror a
|
|
// non-existent dir — the appdata capture below (dst ".../appdata") never fires.
|
|
func TestRunTier2_PaperlessShape(t *testing.T) {
|
|
m, srcDrive, captured := newRunTier2Manager(t, "paperless-ngx", "paperless")
|
|
|
|
if err := m.RunTier2("paperless-ngx"); err != nil {
|
|
t.Fatalf("RunTier2: %v", err)
|
|
}
|
|
|
|
// The appdata leg must mirror appdata/paperless → <destBase>/appdata.
|
|
wantSrc := AppDataDir(srcDrive, "paperless")
|
|
var appdataLeg *[2]string
|
|
for i := range *captured {
|
|
if filepath.Base((*captured)[i][1]) == "appdata" {
|
|
appdataLeg = &(*captured)[i]
|
|
}
|
|
}
|
|
if appdataLeg == nil {
|
|
t.Fatalf("appdata leg was never mirrored (F-S2 regression); captured=%v", *captured)
|
|
}
|
|
if appdataLeg[0] != wantSrc {
|
|
t.Errorf("appdata mirror src = %q, want the resolved dir %q", appdataLeg[0], wantSrc)
|
|
}
|
|
if filepath.Base(appdataLeg[1]) != "appdata" {
|
|
t.Errorf("appdata mirror dst = %q, want flat <destBase>/appdata", appdataLeg[1])
|
|
}
|
|
// Recorded success size must include the paperless bytes (non-empty, > 0).
|
|
cd := m.settings.GetCrossDriveConfig("paperless-ngx")
|
|
if cd == nil || cd.LastStatus != "ok" {
|
|
t.Fatalf("expected recorded ok status, got %+v", cd)
|
|
}
|
|
if cd.LastSizeHuman == "" || cd.LastSizeHuman == "0 B" {
|
|
t.Errorf("recorded size = %q, want it to include the appdata bytes", cd.LastSizeHuman)
|
|
}
|
|
}
|
|
|
|
// TestRunTier2_LegacyShape (Scenario B): a match-name app AND a no-binds app both mirror the exact
|
|
// same src the pre-fix code keyed by stack name — byte-identical behavior.
|
|
func TestRunTier2_LegacyShape(t *testing.T) {
|
|
t.Run("matching name (nextcloud)", func(t *testing.T) {
|
|
m, srcDrive, captured := newRunTier2Manager(t, "nextcloud", "nextcloud")
|
|
m.stackProvider.(*t2rFakeProvider).mounts = []string{filepath.Join(srcDrive, "appdata", "nextcloud")}
|
|
if err := m.RunTier2("nextcloud"); err != nil {
|
|
t.Fatalf("RunTier2: %v", err)
|
|
}
|
|
assertAppdataSrc(t, captured, AppDataDir(srcDrive, "nextcloud"))
|
|
})
|
|
t.Run("no appdata binds (fallback == stack name)", func(t *testing.T) {
|
|
// The appdata dir is created under the stack name; mounts are cleared → resolver falls back.
|
|
m, srcDrive, captured := newRunTier2Manager(t, "vaultwarden", "vaultwarden")
|
|
m.stackProvider.(*t2rFakeProvider).mounts = nil
|
|
if err := m.RunTier2("vaultwarden"); err != nil {
|
|
t.Fatalf("RunTier2: %v", err)
|
|
}
|
|
assertAppdataSrc(t, captured, AppDataDir(srcDrive, "vaultwarden"))
|
|
})
|
|
}
|
|
|
|
func assertAppdataSrc(t *testing.T, captured *[][2]string, want string) {
|
|
t.Helper()
|
|
for _, p := range *captured {
|
|
if filepath.Base(p[1]) == "appdata" {
|
|
if p[0] != want {
|
|
t.Errorf("appdata mirror src = %q, want %q", p[0], want)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("appdata leg not mirrored; captured=%v", *captured)
|
|
}
|
|
|
|
// TestRunTier2_MultiDirRefusal (Scenario C / RP-5): two distinct appdata dirs → an honest no_target
|
|
// status with the EXACT Hungarian reason, an [ERROR] log, and NO mirror call. Companion RP-5:
|
|
// deleting the N>1 guard in tier2AppDataName makes the mirror fire (call-count > 0).
|
|
func TestRunTier2_MultiDirRefusal(t *testing.T) {
|
|
m, srcDrive, captured := newRunTier2Manager(t, "twodir", "alpha")
|
|
m.stackProvider.(*t2rFakeProvider).mounts = []string{
|
|
filepath.Join(srcDrive, "appdata", "alpha", "x"),
|
|
filepath.Join(srcDrive, "appdata", "beta", "y"),
|
|
}
|
|
if err := m.RunTier2("twodir"); err != nil {
|
|
t.Fatalf("RunTier2 must record a status, not error: %v", err)
|
|
}
|
|
if n := len(*captured); n != 0 {
|
|
t.Errorf("mirror was called %d time(s) on a multi-dir refusal — want 0", n)
|
|
}
|
|
cd := m.settings.GetCrossDriveConfig("twodir")
|
|
if cd == nil || cd.LastStatus != "no_target" {
|
|
t.Fatalf("expected no_target status, got %+v", cd)
|
|
}
|
|
if cd.LastError != errTier2MultiDir.Error() {
|
|
t.Errorf("reason = %q, want %q", cd.LastError, errTier2MultiDir.Error())
|
|
}
|
|
}
|
|
|
|
// TestTier2Info_MultiDirRefusal (Scenario C, info tier): the config-panel view surfaces the same
|
|
// refusal reason.
|
|
func TestTier2Info_MultiDirRefusal(t *testing.T) {
|
|
m, srcDrive, _ := newRunTier2Manager(t, "twodir", "alpha")
|
|
m.stackProvider.(*t2rFakeProvider).mounts = []string{
|
|
filepath.Join(srcDrive, "appdata", "alpha", "x"),
|
|
filepath.Join(srcDrive, "appdata", "beta", "y"),
|
|
}
|
|
info := m.Tier2Info("twodir")
|
|
if !info.NoTarget {
|
|
t.Fatal("expected NoTarget on a multi-dir app")
|
|
}
|
|
if info.NoTargetReason != errTier2MultiDir.Error() {
|
|
t.Errorf("reason = %q, want %q", info.NoTargetReason, errTier2MultiDir.Error())
|
|
}
|
|
}
|
|
|
|
// TestRestoreTier2Files_ResolvedLiveDir (Scenario E / RP-3): the copier's dst is the RESOLVED live
|
|
// dir (appdata/paperless), not appdata/<stackName>. Companion RP-3: reverting liveDir to stack-name
|
|
// keying makes dst appdata/paperless-ngx and this fails.
|
|
func TestRestoreTier2Files_ResolvedLiveDir(t *testing.T) {
|
|
m, fake, liveDrive, _ := newT2RManager(t)
|
|
fake.mounts = []string{
|
|
filepath.Join(liveDrive, "appdata", "paperless", "media"),
|
|
}
|
|
var gotDst string
|
|
m.restoreFilesCopier = func(_, dst string) (int, error) { gotDst = dst; return 0, nil }
|
|
|
|
if _, err := m.RestoreTier2Files("app"); err != nil {
|
|
t.Fatalf("restore: %v", err)
|
|
}
|
|
if want := AppDataDir(liveDrive, "paperless"); gotDst != want {
|
|
t.Errorf("restore dst = %q, want resolved live dir %q", gotDst, want)
|
|
}
|
|
}
|
|
|
|
// TestRestoreTier2Files_MultiDirRefusal (Scenario C, restore tier): two dirs → errTier2MultiDirRestore
|
|
// BEFORE the app is stopped (effect assertion: StopStack never invoked).
|
|
func TestRestoreTier2Files_MultiDirRefusal(t *testing.T) {
|
|
m, fake, liveDrive, _ := newT2RManager(t)
|
|
fake.mounts = []string{
|
|
filepath.Join(liveDrive, "appdata", "alpha", "x"),
|
|
filepath.Join(liveDrive, "appdata", "beta", "y"),
|
|
}
|
|
called := false
|
|
m.restoreFilesCopier = func(string, string) (int, error) { called = true; return 0, nil }
|
|
|
|
_, err := m.RestoreTier2Files("app")
|
|
if !errors.Is(err, errTier2MultiDirRestore) {
|
|
t.Fatalf("err = %v, want errTier2MultiDirRestore", err)
|
|
}
|
|
if len(fake.stopped) != 0 {
|
|
t.Errorf("app was STOPPED on a multi-dir refusal: %v", fake.stopped)
|
|
}
|
|
if called {
|
|
t.Error("copier invoked on a refusal")
|
|
}
|
|
}
|