From 8967ba7cb425502c4c2c78e68fe7abda3aeee421 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 14 Jul 2026 15:10:45 +0200 Subject: [PATCH] C6B-F1 cause 2: .fab export mount discovery resolves the userdata convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- controller/cmd/controller/main.go | 7 +- controller/internal/stacks/delete.go | 45 ++++++ .../internal/stacks/export_mounts_test.go | 136 ++++++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 controller/internal/stacks/export_mounts_test.go diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 171ee9f..d887180 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1354,7 +1354,12 @@ func (a *exportAdapter) GetStackHDDMounts(name string) []string { stackDir := filepath.Dir(s.ComposePath) appCfg := stacks.LoadAppConfig(stackDir) if appCfg != nil && appCfg.Env["HDD_PATH"] != "" { - return stacks.ParseComposeHDDMounts(s.ComposePath, appCfg.Env["HDD_PATH"]) + // C6B-F1 (v0.130.0): union ${HDD_PATH} binds + the ${USERDATA_PATH} root. The old + // ParseComposeHDDMounts-only call was blind to the standard userdata convention, so + // 12/13 needs_hdd catalog apps exported hollow (config-only) bundles. The backup-side + // stackAdapter is intentionally NOT changed — the scheduled/tier-2 path copies the + // felhom-data namespace wholesale and its mount list is size/inventory display only. + return stacks.ExportDataMounts(s.ComposePath, appCfg.Env["HDD_PATH"]) } return nil } diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 24c28d5..c8c4d0c 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -10,6 +10,8 @@ import ( "path/filepath" "strings" "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) // felhomDataDir matches backup.FelhomDataDir — duplicated to avoid circular import via StackDataProvider. @@ -552,6 +554,49 @@ func ParseComposeUserdataMounts(composePath, userdataPath string) []string { return mounts } +// ExportDataMounts returns the host directories a .fab export must capture for an app: the +// ${HDD_PATH}-referencing bind mounts PLUS — when the compose binds ${USERDATA_PATH} (the standard +// felhom convention; USERDATA_PATH = /userdata, injected at deploy by withUserdataPath) — +// the userdata ROOT as a single entry. C6B-F1 (v0.130.0): ParseComposeHDDMounts alone never +// resolves ${USERDATA_PATH}, so 12/13 needs_hdd catalog apps exported ZERO userdata (a silent +// hollow bundle that passed the v0.125.0 guard). +// +// The userdata subtree is deliberately captured at its ROOT, not per-bind: the .fab manifest keys +// HDD tars by basename, and the import side maps a basename either to a resolved ${HDD_PATH} mount +// or to / — "userdata" round-trips through that mapping exactly, while a +// nested bind like ${USERDATA_PATH}/media/tv would base to "tv" and restore to the wrong place. +// The root also covers sibling dirs the app created beyond its declared binds (same philosophy as +// the tier-2 namespace-wholesale copy). +// +// Dedupe is containment-aware in both directions: the userdata root is skipped when an HDD mount +// already covers it (an app binding ${HDD_PATH} itself), and HDD mounts inside the userdata root +// are dropped when the root is added (a literal ${HDD_PATH}/userdata/x bind would otherwise +// double-tar and basename-collide with the root). +func ExportDataMounts(composePath, hddPath string) []string { + if hddPath == "" { + return nil + } + hddMounts := ParseComposeHDDMounts(composePath, hddPath) + ud := appbackup.UserdataDir(filepath.Clean(hddPath)) + if len(ParseComposeUserdataMounts(composePath, ud)) == 0 { + return hddMounts + } + for _, m := range hddMounts { + if m == ud || strings.HasPrefix(ud, m+string(filepath.Separator)) { + // an HDD mount already covers the userdata root — nothing to add + return hddMounts + } + } + mounts := make([]string, 0, len(hddMounts)+1) + for _, m := range hddMounts { + if strings.HasPrefix(m, ud+string(filepath.Separator)) { + continue // inside the userdata root — the root tar covers it + } + mounts = append(mounts, m) + } + return append(mounts, ud) +} + // ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths // that reference the HDD path from volume bind mounts. func ParseComposeHDDMounts(composePath, hddPath string) []string { diff --git a/controller/internal/stacks/export_mounts_test.go b/controller/internal/stacks/export_mounts_test.go new file mode 100644 index 0000000..6a81e2f --- /dev/null +++ b/controller/internal/stacks/export_mounts_test.go @@ -0,0 +1,136 @@ +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→/ 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) + } +}