Files
felhom-controller/controller/internal/stacks/import_root_classify_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

326 lines
13 KiB
Go

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/<app> 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 <sysNS>/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")
}
}