4773809334
Found on the demo-felhom deploy leg: ensuring only <sysNS>/userdata/import left its parent at 755 root:root, because EnsureUserdataDir MkdirAll's intermediates at plain 0755 and chmods only the leaf. That made the system drive's userdata root the one on the box outside the 2775/gid-1000 convention.
349 lines
14 KiB
Go
349 lines
14 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")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// <sysNS>/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())
|
|
}
|
|
}
|
|
}
|