package stacks import ( "os" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) // R-75 Scenario B — THE regression gate for moving an ingest bind to ${IMPORT_PATH}. // // ValidateBackupSpec rejects an entry whose (Root, RelPath) matches NO compose bind, and the // rejection is WHOLE-BLOCK: LoadMetadata sets Backup back to nil and the app degrades to LEGACY. // So moving paperless's consume bind to ${IMPORT_PATH} while its backup block still said // `userdata: import/paperless` would discard the ENTIRE block — taking // `hdd: appdata/paperless/media class: mandatory` with it. The customer's document originals would // silently fall back to legacy handling. That is the collateral this test exists to catch. // // The fixtures below are copied VERBATIM from app-catalog-felhom.eu templates/paperless-ngx/ (the // bind lines and the whole backup block). If the catalog changes them, this test must be updated in // the same train — that coupling is the point, and Part-8 leg 1 re-checks it against the live // catalog on a real box. const importPaperlessCompose = `services: paperless-webserver: image: ghcr.io/paperless-ngx/paperless-ngx:2.18.4 volumes: - paperless_data:/usr/src/paperless/data - ${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media - ${HDD_PATH}/appdata/paperless/export:/usr/src/paperless/export - ${IMPORT_PATH}/paperless:/usr/src/paperless/consume volumes: paperless_data: ` const importPaperlessMeta = `display_name: Paperless-ngx slug: paperless-ngx category: documents backup: hdd: - path: appdata/paperless/media class: mandatory - path: appdata/paperless/export class: excluded import: - path: paperless class: excluded ` // writeApp lays out a stack dir with a compose file and a .felhom.yml. func writeApp(t *testing.T, compose, meta string) string { t.Helper() dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(meta), 0o644); err != nil { t.Fatal(err) } return dir } func TestScenarioB_ImportMoveKeepsClassification(t *testing.T) { dir := writeApp(t, importPaperlessCompose, importPaperlessMeta) m := LoadMetadata(dir) if m.Backup == nil { t.Fatal("backup block was WHOLE-BLOCK REJECTED (degraded to legacy) — the import move lost the app's classification") } binds := ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml")) classified, has := appbackup.ClassifyBinds(m.Backup, binds) if !has { t.Fatal("hasClassification=false — the app degraded to legacy") } type key struct { root appbackup.BindRoot rel string } got := map[key]appbackup.ClassifiedBind{} for _, c := range classified { got[key{c.Root, c.RelPath}] = c } want := []struct { root appbackup.BindRoot rel string class appbackup.BindClass origin appbackup.ClassOrigin why string }{ {appbackup.RootImport, "paperless", appbackup.ClassExcluded, appbackup.OriginExplicit, "the moved ingest bind must classify under the import root"}, {appbackup.RootHDD, "appdata/paperless/media", appbackup.ClassMandatory, appbackup.OriginExplicit, "THE COLLATERAL: a whole-block reject would silently drop this to legacy"}, {appbackup.RootHDD, "appdata/paperless/export", appbackup.ClassExcluded, appbackup.OriginExplicit, "second hdd entry must survive too"}, } for _, w := range want { c, ok := got[key{w.root, w.rel}] if !ok { t.Errorf("%s/%s: bind missing entirely — %s", w.root, w.rel, w.why) continue } if c.Class != w.class { t.Errorf("%s/%s: class = %q, want %q — %s", w.root, w.rel, c.Class, w.class, w.why) } if c.Origin != w.origin { t.Errorf("%s/%s: origin = %q, want %q — %s", w.root, w.rel, c.Origin, w.origin, w.why) } } // The explicit WRONG outcome from the scenario: nothing may report legacy. for _, c := range classified { if c.Origin == appbackup.OriginLegacy { t.Errorf("%s/%s reported origin=legacy — the block was rejected", c.Root, c.RelPath) } } } // The companion in the other direction: a STALE `userdata: import/paperless` entry against the new // ${IMPORT_PATH} compose must be REFUSED, and refused WHOLE-BLOCK. This is the state the catalog // would be in if Part 2 moved the compose bind but forgot the backup block — it proves the trap is // real rather than hypothetical, and that the guard catches it rather than silently mis-classifying. func TestScenarioB_StaleUserdataEntryIsWholeBlockRejected(t *testing.T) { staleMeta := `display_name: Paperless-ngx slug: paperless-ngx backup: hdd: - path: appdata/paperless/media class: mandatory userdata: - path: import/paperless class: excluded ` dir := writeApp(t, importPaperlessCompose, staleMeta) if m := LoadMetadata(dir); m.Backup != nil { t.Error("a stale userdata entry matching no compose bind must be whole-block rejected") } // And prove the consequence the scenario names, so the reject is not mistaken for harmless: // with the block gone, the mandatory hdd path loses its class and goes legacy. m := LoadMetadata(dir) binds := ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml")) classified, has := appbackup.ClassifyBinds(m.Backup, binds) if has { t.Fatal("precondition: block should be nil here") } for _, c := range classified { if c.Root == appbackup.RootHDD && c.RelPath == "appdata/paperless/media" { if c.Origin != appbackup.OriginLegacy || c.Class != "" { t.Errorf("expected the collateral to be legacy/unclassed, got class=%q origin=%q", c.Class, c.Origin) } } } } // calibre-web carries BOTH an import bind and a userdata library bind — the multi-root case. func TestScenarioB_CalibreBothRoots(t *testing.T) { compose := `services: calibre-web: image: crocodilestick/calibre-web-automated:v4.0.6 volumes: - calibre_web_config:/config - ${IMPORT_PATH}/calibre:/cwa-book-ingest - ${USERDATA_PATH}/media/books:/calibre-library volumes: calibre_web_config: ` meta := `display_name: Calibre-Web slug: calibre-web backup: userdata: - path: media/books class: mandatory import: - path: calibre class: excluded ` dir := writeApp(t, compose, meta) m := LoadMetadata(dir) if m.Backup == nil { t.Fatal("calibre-web backup block was whole-block rejected") } classified, has := appbackup.ClassifyBinds(m.Backup, ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml"))) if !has { t.Fatal("calibre-web degraded to legacy") } seen := map[string]appbackup.ClassifiedBind{} for _, c := range classified { seen[string(c.Root)+"/"+c.RelPath] = c } if c := seen["import/calibre"]; c.Class != appbackup.ClassExcluded || c.Origin != appbackup.OriginExplicit { t.Errorf("import/calibre: class=%q origin=%q, want excluded/explicit", c.Class, c.Origin) } if c := seen["userdata/media/books"]; c.Class != appbackup.ClassMandatory || c.Origin != appbackup.OriginExplicit { t.Errorf("userdata/media/books: class=%q origin=%q, want mandatory/explicit", c.Class, c.Origin) } } // The import root resolves against the SYSTEM drive, never the app's own drive. Two apps on two // different drives must resolve their ingest folders to the SAME parent — the canonical property. func TestImportBindResolvesToSystemDrive(t *testing.T) { const importRoot = "/mnt/sys_drive/felhom-data/userdata/import" binds := []appbackup.ClassifiedBind{ {ComposeBind: appbackup.ComposeBind{Root: appbackup.RootImport, RelPath: "paperless"}, Class: appbackup.ClassExcluded, Origin: appbackup.OriginExplicit}, } for _, hdd := range []string{"/mnt/felhom-drives/hdd_1", "/mnt/felhom-drives/nvme-1tb"} { fb := appbackup.ComputeFabBuckets(binds, true, hdd, importRoot) if len(fb.Excluded) != 1 { t.Fatalf("hdd=%s: expected 1 excluded bucket entry, got %d (skipped=%v)", hdd, len(fb.Excluded), fb.Skipped) } if got, want := fb.Excluded[0].Abs, importRoot+"/paperless"; got != want { t.Errorf("hdd=%s: import bind resolved to %q, want %q — it must NOT follow the app's drive", hdd, got, want) } } // Unresolvable import root ⇒ refused LOUDLY into Skipped, never joined onto "". fb := appbackup.ComputeFabBuckets(binds, true, "/mnt/felhom-drives/hdd_1", "") if len(fb.Excluded) != 0 { t.Errorf("an unresolvable import root must not resolve: %+v", fb.Excluded) } if len(fb.Skipped) != 1 { t.Fatalf("expected the bind in Skipped, got %+v", fb.Skipped) } } // R-75 Scenario A — the deploy belt puts the drop-zone on the SYSTEM drive and NOWHERE ELSE. // The wrong outcome this guards is a second, non-functional import/ appearing on the data // drive: it would look exactly like a drop-zone, silently do nothing, and (import being // class: excluded) never be backed up either. func TestScenarioA_BeltCreatesImportOnSystemDriveOnly(t *testing.T) { m := newMigManager(t, "") stackDir := writeApp(t, importPaperlessCompose, importPaperlessMeta) dataDrive := t.TempDir() // stands in for /mnt/felhom-drives/hdd_1 sysNS := t.TempDir() // stands in for /mnt/sys_drive/felhom-data userdataPath := appbackup.UserdataDir(dataDrive) importPath := appbackup.ImportDir(sysNS) m.isMountPoint = func(string) bool { return true } // the data drive is attached m.ensureUserdataMounts(stackDir, []string{ "HDD_PATH=" + dataDrive, "USERDATA_PATH=" + userdataPath, "IMPORT_PATH=" + importPath, }) // (1) the drop-zone exists on the SYSTEM drive want := filepath.Join(importPath, "paperless") fi, err := os.Stat(want) if err != nil || !fi.IsDir() { t.Fatalf("belt did not create the canonical drop-zone %s (%v)", want, err) } // (2) with the userdata convention: setgid + group-rwx if perm := fi.Mode().Perm(); perm != 0o775 || fi.Mode()&os.ModeSetgid == 0 { t.Errorf("drop-zone mode = %v, want setgid + 0775 (2775)", fi.Mode()) } // (3) and NOT on the data drive — the wrong outcome named in the scenario if _, err := os.Stat(filepath.Join(userdataPath, "import")); err == nil { t.Errorf("a second drop-zone was created on the data drive at %s — exactly the dead drop-zone R-75 removes", filepath.Join(userdataPath, "import")) } } // A detached data drive must not stop the system-drive drop-zone from being created: the two roots // are on different devices and the drive-absent gate is about the data drive only. func TestScenarioA_ImportBeltNotGatedByDetachedDataDrive(t *testing.T) { m := newMigManager(t, "") stackDir := writeApp(t, importPaperlessCompose, importPaperlessMeta) dataDrive := t.TempDir() sysNS := t.TempDir() importPath := appbackup.ImportDir(sysNS) m.isMountPoint = func(string) bool { return false } // drive DETACHED m.ensureUserdataMounts(stackDir, []string{ "HDD_PATH=" + dataDrive, "USERDATA_PATH=" + appbackup.UserdataDir(dataDrive), "IMPORT_PATH=" + importPath, }) if _, err := os.Stat(filepath.Join(importPath, "paperless")); err != nil { t.Errorf("the system-drive drop-zone must be created even when the DATA drive is detached: %v", err) } // the userdata half stays correctly gated (nothing written onto the rootfs) if _, err := os.Stat(appbackup.UserdataDir(dataDrive)); err == nil { t.Error("the drive-absent gate must still suppress userdata creation on a detached drive") } } // R-75: the canonical drop-zone must never migrate with an app. Migrating an app OFF the system // drive would otherwise drag /userdata/import onto the destination data drive — a second, // non-functional, unbacked drop-zone. func TestImportRootExcludedFromMigration(t *testing.T) { m := newMigManager(t, "") sysNS := appbackup.NamespaceRoot(m.cfg.Paths.SystemDataPath, false) importRoot := appbackup.ImportDir(sysNS) // App migrating OFF the system drive: source namespace IS the system namespace. offSystem := m.appDataSkipSet(&MigrationJob{SourceNS: sysNS, Apps: []string{"paperless-ngx"}}) if !offSystem[filepath.Clean(importRoot)] { t.Errorf("import root %q must be pruned from a migration off the system drive; skip set = %v", importRoot, offSystem) } // App migrating OFF a data drive: no import root there, nothing extra to prune. dataNS := "/mnt/felhom-drives/hdd_1" offData := m.appDataSkipSet(&MigrationJob{SourceNS: dataNS, Apps: []string{"paperless-ngx"}}) if offData[filepath.Clean(importRoot)] { t.Error("a data-drive migration must not carry a system-drive skip entry") } } // pathUnder must be segment-wise: a sibling sharing a name prefix is NOT contained. func TestPathUnderIsSegmentWise(t *testing.T) { root := filepath.Clean("/mnt/sys_drive") if !pathUnder(root, root) { t.Error("a path must be under itself") } if !pathUnder(filepath.Join(root, "felhom-data", "userdata"), root) { t.Error("a descendant must be under the root") } if pathUnder(filepath.Clean("/mnt/sys_drive-evil/x"), root) { t.Error("a name-prefix sibling must NOT be under the root") } } // EnsureImportRoot must apply the convention to the import dir AND its parent userdata dir. // Observed live on demo-felhom (v0.172.0 first cut): ensuring only the leaf left // /userdata at 755 root:root, the one userdata root on the box outside the convention. func TestEnsureImportRoot_ParentCarriesTheConvention(t *testing.T) { m := newMigManager(t, "") m.cfg.Paths.SystemDataPath = t.TempDir() // writable stand-in for /mnt/sys_drive if err := m.EnsureImportRoot(); err != nil { // chown to gid 1000 fails for a non-root test user; the modes still land. t.Logf("EnsureImportRoot returned %v (expected off-root)", err) } sysNS := appbackup.NamespaceRoot(m.cfg.Paths.SystemDataPath, false) for _, p := range []string{appbackup.UserdataDir(sysNS), appbackup.ImportDir(sysNS)} { fi, err := os.Stat(p) if err != nil { t.Fatalf("%s not created: %v", p, err) } if fi.Mode().Perm() != 0o775 || fi.Mode()&os.ModeSetgid == 0 { t.Errorf("%s mode = %v, want setgid + 0775 (2775)", p, fi.Mode()) } } }