2958946517
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).
Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.
Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.
Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.
One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.
Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.
Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.
Tests 915 -> 949, all green. MinAgent unchanged.
354 lines
16 KiB
Go
354 lines
16 KiB
Go
package backup
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// t2v2Provider is a configurable StackDataProvider for the v2 tier-2 tests (per-stack hdd/mounts/binds).
|
|
type t2v2Provider struct {
|
|
hdd string
|
|
mounts map[string][]string
|
|
binds map[string][]ClassifiedBind
|
|
has map[string]bool
|
|
}
|
|
|
|
func (p *t2v2Provider) GetStackComposePath(string) (string, bool) { return "", false }
|
|
func (p *t2v2Provider) ListDeployedStacks() []StackSummary { return nil }
|
|
func (p *t2v2Provider) GetStackHDDMounts(n string) []string { return p.mounts[n] }
|
|
func (p *t2v2Provider) GetStackHDDPath(string) string { return p.hdd }
|
|
func (p *t2v2Provider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
|
|
func (p *t2v2Provider) GetDockerVolumes(string) []string { return nil }
|
|
func (p *t2v2Provider) StopStack(string) error { return nil }
|
|
func (p *t2v2Provider) StartStack(string) error { return nil }
|
|
func (p *t2v2Provider) RefreshAndIsRunning(string) bool { return true }
|
|
func (p *t2v2Provider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
|
return RecoveryInfo{}, false
|
|
}
|
|
func (p *t2v2Provider) RecoverStackSecrets(string, []string) map[string]string { return nil }
|
|
func (p *t2v2Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
|
|
return nil
|
|
}
|
|
func (p *t2v2Provider) StartStackServices(string, []string) error { return nil }
|
|
func (p *t2v2Provider) GetStackClassifiedBinds(n string) ([]ClassifiedBind, bool) {
|
|
return p.binds[n], p.has[n]
|
|
}
|
|
|
|
// newTier2V2 builds a Manager with a source drive (holding a recovery unit) + a registered off-drive
|
|
// target, a real (copyTree) mirror seam so the v2 dest tree forms on disk, and the SSD headroom seam
|
|
// forced to "fits" so the state-only branch is testable on any platform.
|
|
func newTier2V2(t *testing.T, stack string) (m *Manager, src, target string, prov *t2v2Provider) {
|
|
t.Helper()
|
|
tmp := t.TempDir()
|
|
src = filepath.Join(tmp, "usb")
|
|
target = filepath.Join(tmp, "flash")
|
|
sys := filepath.Join(tmp, "sys")
|
|
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: "flash", Schedulable: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mustWrite(t, filepath.Join(RecoveryUnitPath(src, stack), "manifest.json"), "{}")
|
|
prov = &t2v2Provider{hdd: src, mounts: map[string][]string{}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.SystemDataPath = sys
|
|
m = NewManager(cfg, sett, log.New(io.Discard, "", 0))
|
|
m.stackProvider = prov
|
|
m.systemDataPath = sys
|
|
m.tier2Mirror = copyTree
|
|
m.tier2SSDFits = func(string, int64) bool { return true }
|
|
m.samePhysicalDevice = oneDrivePerSubtree // src/target/sys are separate "drives" on one filesystem
|
|
return m, src, target, prov
|
|
}
|
|
|
|
func mHDD(rel string) ClassifiedBind {
|
|
return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassMandatory}
|
|
}
|
|
func oUD(rel string) ClassifiedBind {
|
|
return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel, ReadOnly: true}, Class: appbackup.ClassOptional}
|
|
}
|
|
func xHDD(rel string) ClassifiedBind {
|
|
return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassExcluded}
|
|
}
|
|
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
|
|
|
|
// A — classified paperless: v2 tree, export ABSENT (copy shrinks), marker written.
|
|
func TestTier2V2_ClassifiedPaperless(t *testing.T) {
|
|
m, src, target, prov := newTier2V2(t, "paperless-ngx")
|
|
prov.has["paperless-ngx"] = true
|
|
prov.binds["paperless-ngx"] = []ClassifiedBind{mHDD("appdata/paperless/media"), xHDD("appdata/paperless/export"), xHDD("import/paperless")}
|
|
// mounts point at the whole appdata/paperless dir — the LEGACY resolver set (captured only if a bug
|
|
// routes a classified app through it; the A red-proof). The classified path ignores mounts.
|
|
prov.mounts["paperless-ngx"] = []string{filepath.Join(src, "appdata", "paperless", "media"), filepath.Join(src, "appdata", "paperless", "export")}
|
|
mustWrite(t, filepath.Join(src, "appdata", "paperless", "media", "doc.pdf"), "PDF")
|
|
mustWrite(t, filepath.Join(src, "appdata", "paperless", "export", "junk.zip"), "ZIP")
|
|
|
|
if err := m.RunTier2("paperless-ngx"); err != nil {
|
|
t.Fatalf("RunTier2: %v", err)
|
|
}
|
|
destBase := filepath.Join(target, "backups", "secondary", "paperless-ngx")
|
|
if got, _ := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker)); string(got) != "2" {
|
|
t.Errorf("marker = %q, want 2", got)
|
|
}
|
|
if !exists(filepath.Join(destBase, "hdd", "appdata", "paperless", "media", "doc.pdf")) {
|
|
t.Error("mandatory media leg missing from v2 dest")
|
|
}
|
|
if exists(filepath.Join(destBase, "hdd", "appdata", "paperless", "export")) {
|
|
t.Error("excluded export must NOT appear in the tier-2 copy (legacy set leaking)")
|
|
}
|
|
if !exists(filepath.Join(destBase, "recovery-unit", "manifest.json")) {
|
|
t.Error("recovery-unit leg missing")
|
|
}
|
|
}
|
|
|
|
// B — legacy: resolver dirs as legs, N>1 works (no refusal).
|
|
func TestTier2V2_LegacyMultiDir(t *testing.T) {
|
|
m, src, target, prov := newTier2V2(t, "twodir")
|
|
prov.has["twodir"] = false
|
|
prov.mounts["twodir"] = []string{filepath.Join(src, "appdata", "alpha", "x"), filepath.Join(src, "appdata", "beta", "y")}
|
|
mustWrite(t, filepath.Join(src, "appdata", "alpha", "a.txt"), "A")
|
|
mustWrite(t, filepath.Join(src, "appdata", "beta", "b.txt"), "B")
|
|
|
|
if err := m.RunTier2("twodir"); err != nil {
|
|
t.Fatalf("RunTier2 must SUCCEED for N>1 now (refusal lifted): %v", err)
|
|
}
|
|
destBase := filepath.Join(target, "backups", "secondary", "twodir")
|
|
if !exists(filepath.Join(destBase, "hdd", "appdata", "alpha", "a.txt")) || !exists(filepath.Join(destBase, "hdd", "appdata", "beta", "b.txt")) {
|
|
t.Error("both legacy appdata dirs must be mirrored as separate v2 legs")
|
|
}
|
|
if cd := m.settings.GetCrossDriveConfig("twodir"); cd == nil || cd.LastStatus != "ok" {
|
|
t.Errorf("want recorded ok, got %+v", cd)
|
|
}
|
|
}
|
|
|
|
// C — migration = rebuild, marker LAST; a leg failure leaves NO marker.
|
|
func TestTier2V2_MigrationAndMarkerLast(t *testing.T) {
|
|
m, src, target, prov := newTier2V2(t, "app")
|
|
prov.has["app"] = true
|
|
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/data")}
|
|
mustWrite(t, filepath.Join(src, "appdata", "app", "data", "f"), "F")
|
|
destBase := filepath.Join(target, "backups", "secondary", "app")
|
|
// Pre-v2 dest: recovery-unit + old flat appdata, NO marker.
|
|
mustWrite(t, filepath.Join(destBase, "recovery-unit", "old.json"), "{}")
|
|
mustWrite(t, filepath.Join(destBase, "appdata", "stale.bin"), "STALE")
|
|
|
|
if err := m.RunTier2("app"); err != nil {
|
|
t.Fatalf("RunTier2: %v", err)
|
|
}
|
|
if exists(filepath.Join(destBase, "appdata")) {
|
|
t.Error("old flat appdata/ must be removed on migration")
|
|
}
|
|
if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "data", "f")) {
|
|
t.Error("v2 legs must be mirrored")
|
|
}
|
|
if got, _ := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker)); string(got) != "2" {
|
|
t.Errorf("marker not written LAST: %q", got)
|
|
}
|
|
|
|
// Marker-LAST on failure: a fresh app whose leg mirror fails must leave NO marker.
|
|
m2, src2, target2, prov2 := newTier2V2(t, "boom")
|
|
prov2.has["boom"] = true
|
|
prov2.binds["boom"] = []ClassifiedBind{mHDD("appdata/boom/x")}
|
|
mustWrite(t, filepath.Join(src2, "appdata", "boom", "x", "f"), "F")
|
|
m2.tier2Mirror = func(s, d string) error {
|
|
if strings.Contains(d, "boom") && strings.Contains(d, filepath.Join("hdd", "appdata")) {
|
|
return os.ErrPermission // fail the LEG (not the unit)
|
|
}
|
|
return copyTree(s, d)
|
|
}
|
|
if err := m2.RunTier2("boom"); err == nil {
|
|
t.Fatal("a leg mirror failure must surface as an error")
|
|
}
|
|
if exists(filepath.Join(target2, "backups", "secondary", "boom", tier2LayoutMarker)) {
|
|
t.Error("marker must NOT be written when a leg fails (self-heal next run)")
|
|
}
|
|
}
|
|
|
|
// D — reconcile: stale dir removed, ancestor/descendant kept; + the safe-remove boundary proof.
|
|
func TestTier2V2_Reconcile(t *testing.T) {
|
|
m, src, target, prov := newTier2V2(t, "app")
|
|
prov.has["app"] = true
|
|
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/media")}
|
|
mustWrite(t, filepath.Join(src, "appdata", "app", "media", "keep.jpg"), "J")
|
|
destBase := filepath.Join(target, "backups", "secondary", "app")
|
|
// Seed the dest with the marker + stale dirs a prior run left.
|
|
mustWrite(t, filepath.Join(destBase, tier2LayoutMarker), "2")
|
|
mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "app", "media", "covers", "c.jpg"), "C") // descendant of leg → keep
|
|
mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "app", "export", "e.bin"), "E") // sibling of leg → stale
|
|
mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "other", "o.bin"), "O") // unrelated → stale
|
|
|
|
if err := m.RunTier2("app"); err != nil {
|
|
t.Fatalf("RunTier2: %v", err)
|
|
}
|
|
if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "media", "covers", "c.jpg")) {
|
|
t.Error("descendant content under a leg must be kept")
|
|
}
|
|
if exists(filepath.Join(destBase, "hdd", "appdata", "app", "export")) {
|
|
t.Error("stale sibling dir (re-classed/removed bind) must be reconciled away")
|
|
}
|
|
if exists(filepath.Join(destBase, "hdd", "appdata", "other")) {
|
|
t.Error("unrelated stale dir must be reconciled away")
|
|
}
|
|
|
|
// Boundary proof: tier2SafeRemove refuses a target outside backups/secondary/.
|
|
if err := tier2SafeRemove(destBase, filepath.Join(target, "live-appdata")); err == nil {
|
|
t.Error("tier2SafeRemove must REFUSE a target outside destBase")
|
|
}
|
|
if err := tier2SafeRemove("/some/other/root", filepath.Join("/some/other/root", "x")); err == nil {
|
|
t.Error("tier2SafeRemove must REFUSE a destBase not under backups/secondary/")
|
|
}
|
|
}
|
|
|
|
// E — SSD state-only: optional legs dropped, mandatory kept, honest warning.
|
|
func TestTier2V2_SSDStateOnly(t *testing.T) {
|
|
m, src, target, prov := newTier2V2(t, "app")
|
|
// Remove the off-drive data-drive target so the auto-pick falls to the SSD (state-only).
|
|
_ = target
|
|
_ = m.settings.RemoveStoragePath(target)
|
|
prov.has["app"] = true
|
|
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/state"), oUD("media/pics")}
|
|
mustWrite(t, filepath.Join(src, "appdata", "app", "state", "db.sql"), "SQL")
|
|
mustWrite(t, filepath.Join(src, "userdata", "media", "pics", "p.jpg"), "J")
|
|
|
|
if err := m.RunTier2("app"); err != nil {
|
|
t.Fatalf("RunTier2: %v", err)
|
|
}
|
|
// The SSD dest is under the system data path (NamespaceRoot(sys, false) → sys/felhom-data).
|
|
destBase := filepath.Join(NamespaceRoot(m.systemDataPath, false), "backups", "secondary", "app")
|
|
if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "state", "db.sql")) {
|
|
t.Error("mandatory state leg must be on the SSD")
|
|
}
|
|
if exists(filepath.Join(destBase, "userdata", "media", "pics")) {
|
|
t.Error("optional leg must NOT be copied to the SSD (state-only tier)")
|
|
}
|
|
cd := m.settings.GetCrossDriveConfig("app")
|
|
if cd == nil || !strings.Contains(cd.LastWarning, "választható tartalom nem került másolásra") {
|
|
t.Errorf("state-only warning missing: %+v", cd)
|
|
}
|
|
}
|
|
|
|
// F1/F2 — network exclusion, pinned AND auto (two separate assertions).
|
|
func TestTier2V2_NetworkExclusion(t *testing.T) {
|
|
// F2 (auto): an NFS candidate registered BEFORE a real local drive → the NFS is skipped and the
|
|
// local drive wins (NFS-first ordering makes this a genuine red-proof of the auto IsNetwork skip).
|
|
t.Run("auto skips network, picks local", func(t *testing.T) {
|
|
m, _, fixtureTarget, _ := newTier2V2(t, "app")
|
|
_ = m.settings.RemoveStoragePath(fixtureTarget) // drop the fixture drive, re-add after the NAS
|
|
nas := "/mnt/nas"
|
|
if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
local := t.TempDir()
|
|
if err := m.settings.AddStoragePath(settings.StoragePath{Path: local, Label: "local", Schedulable: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target, err := m.selectTier2Target("app", 1<<20, 1<<20)
|
|
if err != nil {
|
|
t.Fatalf("selectTier2Target: %v", err)
|
|
}
|
|
if strings.Contains(target.NamespaceRoot, "nas") {
|
|
t.Errorf("the NFS path (iterated first) must be skipped, got %q", target.NamespaceRoot)
|
|
}
|
|
if target.NamespaceRoot != filepath.FromSlash(local) {
|
|
t.Errorf("want the local drive %q, got %q", local, target.NamespaceRoot)
|
|
}
|
|
})
|
|
// F2b: NAS is the ONLY off-disk candidate → the honest network reason.
|
|
t.Run("network-only → honest reason", func(t *testing.T) {
|
|
m, _, target, _ := newTier2V2(t, "app")
|
|
_ = m.settings.RemoveStoragePath(target) // drop the local off-drive target
|
|
nas := "/mnt/nas"
|
|
if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.systemDataPath = "" // no SSD either → network is the only candidate
|
|
_, err := m.selectTier2Target("app", 1<<20, 1<<20)
|
|
if err == nil || !strings.Contains(err.Error(), "Hálózati tároló nem lehet") {
|
|
t.Fatalf("network-only must refuse with the honest reason, got %v", err)
|
|
}
|
|
})
|
|
// F1 (pinned): a pinned NFS target is invalid → falls through to auto (the local drive).
|
|
t.Run("pinned network invalid → falls through", func(t *testing.T) {
|
|
m, _, target, _ := newTier2V2(t, "app")
|
|
nas := "/mnt/nas"
|
|
if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.settings.SetTier2Preference("app", false, nas); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := m.selectTier2Target("app", 1<<20, 1<<20)
|
|
if err != nil {
|
|
t.Fatalf("selectTier2Target: %v", err)
|
|
}
|
|
if got.NamespaceRoot != filepath.FromSlash(target) {
|
|
t.Errorf("pinned NFS must fall through to the local auto-pick %q, got %q", target, got.NamespaceRoot)
|
|
}
|
|
})
|
|
}
|
|
|
|
// G2 — restore refuses a pre-v2 (no marker) copy.
|
|
func TestTier2V2_RestoreRefusesOldLayout(t *testing.T) {
|
|
m, fake, _, destDrive := newT2RManager(t)
|
|
_ = fake
|
|
// Remove the marker the v2 fixture wrote → simulate an old flat copy.
|
|
if err := os.Remove(filepath.Join(destDrive, "backups", "secondary", "app", tier2LayoutMarker)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.restoreFilesCopier = func(string, string) (int, error) {
|
|
t.Fatal("copier must not run on an old-layout refusal")
|
|
return 0, nil
|
|
}
|
|
if _, err := m.RestoreTier2Files("app"); err == nil || !strings.Contains(err.Error(), "régi formátumú") {
|
|
t.Fatalf("restore must refuse a pre-v2 copy with the marker-refusal string, got %v", err)
|
|
}
|
|
}
|
|
|
|
// H — a structurally-refused MANDATORY path is a loud gap in the app's warning.
|
|
func TestTier2V2_CaptureGapLoud(t *testing.T) {
|
|
m, src, _, prov := newTier2V2(t, "app")
|
|
prov.has["app"] = true
|
|
prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/ok"), mHDD("../evil")} // traversal → Skipped
|
|
mustWrite(t, filepath.Join(src, "appdata", "app", "ok", "f"), "F")
|
|
|
|
if err := m.RunTier2("app"); err != nil {
|
|
t.Fatalf("RunTier2: %v", err)
|
|
}
|
|
cd := m.settings.GetCrossDriveConfig("app")
|
|
if cd == nil || !strings.Contains(cd.LastWarning, "nem kerültek a másodlagos mentésbe") {
|
|
t.Errorf("capture-gap warning missing from tier-2 status: %+v", cd)
|
|
}
|
|
}
|
|
|
|
// classifyTier2Rel keep/remove truth table (the reconcile core, pure).
|
|
func TestClassifyTier2Rel(t *testing.T) {
|
|
legs := []string{"hdd/appdata/app/media", "userdata/media/books"}
|
|
cases := []struct {
|
|
rel string
|
|
want tier2RelClass
|
|
}{
|
|
{"hdd/appdata/app/media", tier2KeepInside}, // exact leg
|
|
{"hdd/appdata/app/media/covers", tier2KeepInside}, // descendant
|
|
{"hdd/appdata", tier2KeepAncestor}, // ancestor
|
|
{"hdd/appdata/app", tier2KeepAncestor}, // ancestor
|
|
{"hdd/appdata/app/export", tier2Stale}, // sibling → stale
|
|
{"hdd/other", tier2Stale}, // unrelated → stale
|
|
{"userdata/media", tier2KeepAncestor}, // ancestor of userdata leg
|
|
}
|
|
for _, c := range cases {
|
|
if got := classifyTier2Rel(c.rel, legs); got != c.want {
|
|
t.Errorf("classifyTier2Rel(%q) = %d, want %d", c.rel, got, c.want)
|
|
}
|
|
}
|
|
}
|