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).
This commit is contained in:
@@ -1354,7 +1354,12 @@ func (a *exportAdapter) GetStackHDDMounts(name string) []string {
|
|||||||
stackDir := filepath.Dir(s.ComposePath)
|
stackDir := filepath.Dir(s.ComposePath)
|
||||||
appCfg := stacks.LoadAppConfig(stackDir)
|
appCfg := stacks.LoadAppConfig(stackDir)
|
||||||
if appCfg != nil && appCfg.Env["HDD_PATH"] != "" {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||||
)
|
)
|
||||||
|
|
||||||
// felhomDataDir matches backup.FelhomDataDir — duplicated to avoid circular import via StackDataProvider.
|
// felhomDataDir matches backup.FelhomDataDir — duplicated to avoid circular import via StackDataProvider.
|
||||||
@@ -552,6 +554,49 @@ func ParseComposeUserdataMounts(composePath, userdataPath string) []string {
|
|||||||
return mounts
|
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 = <HDD_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 <HDD_PATH>/<basename> — "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
|
// ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths
|
||||||
// that reference the HDD path from volume bind mounts.
|
// that reference the HDD path from volume bind mounts.
|
||||||
func ParseComposeHDDMounts(composePath, hddPath string) []string {
|
func ParseComposeHDDMounts(composePath, hddPath string) []string {
|
||||||
|
|||||||
@@ -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→<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user