Files
felhom-controller/controller/internal/stacks/export_mounts_test.go
T
admin 8967ba7cb4 C6B-F1 cause 2: .fab export mount discovery resolves the userdata convention
exportAdapter.GetStackHDDMounts now returns stacks.ExportDataMounts — the
${HDD_PATH} binds UNIONed with the ${USERDATA_PATH} ROOT (captured at the
root, not per-bind, so the manifest's basename keying round-trips through the
existing import mapping without touching restore). Containment-aware dedupe
both directions. The backup-side stackAdapter is intentionally unchanged.
Red-proof: pre-fix behavior fails TestExportDataMounts_UserdataConvention/
MixedBindsUnion/LiteralUserdataBindDeduped (run->fail->revert recorded).
2026-07-14 15:10:45 +02:00

137 lines
4.7 KiB
Go

package stacks
import (
"os"
"path/filepath"
"reflect"
"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)
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)
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)
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)
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)
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)
}
}