Files
felhom-controller/controller/internal/backup/tier2_v2_test.go
T
admin 2958946517 v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces
${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.
2026-07-26 08:12:57 +02:00

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)
}
}
}