Files
felhom-controller/controller/internal/stacks/export_mounts_test.go
T
admin a96c3d9473
gates / gates (push) Successful in 9s
R-203: the export-mount resolver takes the namespace root too (its own commit)
ExportDataMounts lives in delete.go, which reads as a destructive path. IT IS NOT: its single
production caller is the .fab export adapter, and nothing deletes based on its result. The
delete path's own guard, ProtectedHDDPaths, is layout-agnostic by construction -- it protects
BOTH <hdd>/... and <hdd>/felhom-data/... -- so deletion was never affected by the
namespace-root defect. That scope note is now in the function's doc comment, because the file
placement will mislead the next reader exactly as it misled the spec for this change.

Separated into its own commit anyway, so a change to a function whose filename says "delete"
is reviewable on its own.

An empty nsRoot falls back to hddPath -- the pre-R-203 shape -- so any caller not yet updated
keeps working on enrolled drives.

Tests cover both drive kinds and assert the NEGATIVE: no emitted path lies outside the app's
own data roots. Red-proof: leaving the site bare fails the system-drive row, emitting
/mnt/sys_drive/userdata where the canonical root is /mnt/sys_drive/felhom-data/userdata.
2026-08-04 18:21:17 +02:00

202 lines
7.0 KiB
Go

package stacks
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// C6B-F1 (v0.130.0) — ExportDataMounts is the .fab export's mount discovery: the ${HDD_PATH}
// binds UNIONed with the ${USERDATA_PATH} root. These tests pin the union, the containment
// dedupe, and — as the red-proof's contrast — that the old ${HDD_PATH}-only scanner alone
// finds NOTHING for the standard media-app compose shape (the exact hollow-bundle cause).
func writeCompose(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "docker-compose.yml")
if err := os.WriteFile(p, []byte(body), 0644); err != nil {
t.Fatal(err)
}
return p
}
// the sonarr shape: named volume + ${USERDATA_PATH} binds, NO direct ${HDD_PATH} bind.
const sonarrShapeCompose = `services:
sonarr:
image: lscr.io/linuxserver/sonarr:4.0.13
volumes:
- sonarr_config:/config
- ${USERDATA_PATH}/media/tv:/media/tv
- ${USERDATA_PATH}/downloads:/downloads
networks:
- traefik-public
volumes:
sonarr_config:
`
func TestExportDataMounts_UserdataConvention(t *testing.T) {
compose := writeCompose(t, sonarrShapeCompose)
hdd := filepath.Join(string(filepath.Separator)+"mnt", "drive", "sonarr")
// RED-PROOF CONTRAST (C6B-F1 cause 2): the pre-fix scanner alone finds ZERO mounts for
// this compose — this is exactly why 12/13 needs_hdd apps exported hollow bundles.
if old := ParseComposeHDDMounts(compose, hdd); len(old) != 0 {
t.Fatalf("precondition drifted: ParseComposeHDDMounts found %v — the compose shape no longer reproduces C6B-F1", old)
}
got := ExportDataMounts(compose, hdd, hdd)
want := []string{filepath.Join(hdd, "userdata")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ExportDataMounts = %v, want the userdata ROOT %v", got, want)
}
// The basename MUST be "userdata" — a direct child of HDD_PATH — so the import side's
// basename→<HDD_PATH>/<subdir> fallback places the tar correctly without import changes.
if filepath.Base(got[0]) != "userdata" {
t.Fatalf("userdata mount bases to %q — the restore mapping requires \"userdata\"", filepath.Base(got[0]))
}
}
func TestExportDataMounts_HDDDirectAppUnchanged(t *testing.T) {
// The 1/13 app that worked pre-fix: direct ${HDD_PATH} binds, no userdata refs.
compose := writeCompose(t, `services:
app:
image: x
volumes:
- ${HDD_PATH}/data:/data
- ${HDD_PATH}/incoming:/incoming
`)
hdd := filepath.Join(string(filepath.Separator)+"mnt", "drive", "app")
got := ExportDataMounts(compose, hdd, hdd)
want := []string{filepath.Join(hdd, "data"), filepath.Join(hdd, "incoming")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ExportDataMounts = %v, want unchanged HDD mounts %v (regression: the one working app must keep working)", got, want)
}
}
func TestExportDataMounts_MixedBindsUnion(t *testing.T) {
compose := writeCompose(t, `services:
app:
image: x
volumes:
- ${HDD_PATH}/direct:/direct
- ${USERDATA_PATH}/media:/media
`)
hdd := filepath.Join(string(filepath.Separator)+"mnt", "drive", "app")
got := ExportDataMounts(compose, hdd, hdd)
want := []string{filepath.Join(hdd, "direct"), filepath.Join(hdd, "userdata")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ExportDataMounts = %v, want the union %v", got, want)
}
}
func TestExportDataMounts_HDDRootCoversUserdata(t *testing.T) {
// An app binding ${HDD_PATH} itself already captures the userdata subtree — the root
// must NOT be added again (double-tar).
compose := writeCompose(t, `services:
app:
image: x
volumes:
- ${HDD_PATH}:/all
- ${USERDATA_PATH}/media:/media
`)
hdd := filepath.Join(string(filepath.Separator)+"mnt", "drive", "app")
got := ExportDataMounts(compose, hdd, hdd)
want := []string{hdd}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ExportDataMounts = %v, want just the covering HDD root %v", got, want)
}
}
func TestExportDataMounts_LiteralUserdataBindDeduped(t *testing.T) {
// A literal ${HDD_PATH}/userdata/foo bind is INSIDE the userdata root — keeping it would
// double-tar and basename-collide with the root tar.
compose := writeCompose(t, `services:
app:
image: x
volumes:
- ${HDD_PATH}/userdata/foo:/foo
- ${USERDATA_PATH}/bar:/bar
`)
hdd := filepath.Join(string(filepath.Separator)+"mnt", "drive", "app")
got := ExportDataMounts(compose, hdd, hdd)
want := []string{filepath.Join(hdd, "userdata")}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ExportDataMounts = %v, want only the userdata root %v", got, want)
}
}
func TestExportDataMounts_EmptyHDDPath(t *testing.T) {
compose := writeCompose(t, sonarrShapeCompose)
if got := ExportDataMounts(compose, "", ""); got != nil {
t.Fatalf("ExportDataMounts with empty HDD_PATH = %v, want nil", got)
}
}
// R-203 / Scenario E — the export-mount resolver on BOTH drive kinds, and what it does NOT return.
//
// This function lives in delete.go, which reads as a destructive path; it is not. Its only production
// caller is the .fab export adapter. The assertions below therefore prove the NEGATIVE that matters
// for an export: no path outside the app's own data roots is ever emitted.
func TestExportDataMounts_BothDriveKinds(t *testing.T) {
const sys = "/mnt/sys_drive"
compose := writeCompose(t, `services:
app:
volumes:
- ${USERDATA_PATH}/media/books:/books
- ${HDD_PATH}/appdata/app:/data
`)
cases := []struct {
name, hdd, nsRoot, wantUD string
}{
{"enrolled drive", "/mnt/felhom-usb", "/mnt/felhom-usb", "/mnt/felhom-usb/userdata"},
{"system drive", sys, sys + "/felhom-data", sys + "/felhom-data/userdata"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ExportDataMounts(compose, tc.hdd, tc.nsRoot)
var sawUD bool
for _, m := range got {
if m == tc.wantUD {
sawUD = true
}
// THE NEGATIVE: nothing outside the app's own roots, on either kind.
if !strings.HasPrefix(m, tc.hdd) && !strings.HasPrefix(m, tc.nsRoot) {
t.Errorf("emitted %q, which is outside both %q and %q", m, tc.hdd, tc.nsRoot)
}
for _, forbidden := range []string{"/etc", "/opt/docker/stacks", "/var/lib/docker", "/root"} {
if strings.HasPrefix(m, forbidden) {
t.Errorf("emitted a path outside the app's data: %q", m)
}
}
}
if !sawUD {
t.Errorf("userdata root %q not emitted; got %v", tc.wantUD, got)
}
})
}
}
// An empty nsRoot falls back to hddPath — the pre-R-203 shape — so a caller that has not been
// updated keeps working on enrolled drives rather than emitting an empty-rooted path.
func TestExportDataMounts_EmptyNamespaceRootFallsBack(t *testing.T) {
compose := writeCompose(t, `services:
app:
volumes:
- ${USERDATA_PATH}/x:/x
`)
withNS := ExportDataMounts(compose, "/mnt/felhom-usb", "/mnt/felhom-usb")
without := ExportDataMounts(compose, "/mnt/felhom-usb", "")
if len(withNS) != len(without) {
t.Fatalf("fallback changed the result: %v vs %v", withNS, without)
}
for i := range withNS {
if withNS[i] != without[i] {
t.Fatalf("fallback changed %q to %q", withNS[i], without[i])
}
}
}