cf9ce01917
SQ6 over-capture FIXED for classified apps: the userdata root tar is exclude-scoped (keeps only ancestors/descendants of a SELECTED bind relpath — R1-C, the tier2Reconcile keep-rule); no selected userdata bind → no root tar (radarr state-only). New appbackup.ComputeFabBuckets (shared resolveGuardCollapse pipeline; class buckets; guards over ALL classes; no cross-bucket containment). appexport/fabplan.go: computeFabPlan + tarDirectoryExcluding + fabEstimateSplit. ExportRequest gains DeselectOptional/OptInExcluded (both start handlers — two-call-site); mandatory is a server-side floor. Manifest v1 + import UNTOUCHED; legacy apps byte-identical to v0.130.0. Estimate additive class split; export UI: locked-mandatory/optional-checkboxes/excluded-opt-in + two-number warning. All 6 §10 red-proofs verified. 6D Accept #1 now runs against this shape.
139 lines
5.3 KiB
Go
139 lines
5.3 KiB
Go
package appexport
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
)
|
|
|
|
func fabWrite(t *testing.T, root, rel, content string) {
|
|
t.Helper()
|
|
p := filepath.Join(root, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Scenario C at the export level: the userdata.tar keeps the mandatory subtree and NOT the siblings,
|
|
// and the manifest still lists the single `userdata` basename (v1 unchanged). The bundle-level anchor
|
|
// for the SQ6 fix (mirrors the §13 before/after).
|
|
func TestFabExport_ExcludeScopedUserdataTar(t *testing.T) {
|
|
drive := t.TempDir() // hddPath
|
|
fabWrite(t, drive, "userdata/media/books/a.epub", "BOOK")
|
|
fabWrite(t, drive, "userdata/media/movies/big.mkv", "MOVIE")
|
|
fabWrite(t, drive, "userdata/music/s.flac", "SONG")
|
|
|
|
ud := appbackup.UserdataDir(filepath.Clean(drive))
|
|
prov := &fabProv{
|
|
rtProvider: &rtProvider{}, hddPath: drive, has: true,
|
|
binds: []appbackup.ClassifiedBind{mUD("media/books"), xUD("media/movies")},
|
|
mounts: []string{ud},
|
|
}
|
|
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
|
|
|
|
dataDir := t.TempDir()
|
|
man := &Manifest{}
|
|
if err := e.exportHDDData(ExportRequest{StackName: "calibre-web"}, dataDir, man); err != nil {
|
|
t.Fatalf("exportHDDData: %v", err)
|
|
}
|
|
|
|
entries := tarEntries(t, filepath.Join(dataDir, "hdd", "userdata.tar"))
|
|
if !containsSuffix(entries, "media/books/a.epub") {
|
|
t.Errorf("mandatory media/books missing from userdata.tar: %v", entries)
|
|
}
|
|
for _, sib := range []string{"media/movies/big.mkv", "media/movies", "music/s.flac", "music"} {
|
|
if containsSuffix(entries, sib) {
|
|
t.Errorf("sibling %q must NOT ride along (SQ6): %v", sib, entries)
|
|
}
|
|
}
|
|
// v1 manifest: the single `userdata` basename, unchanged.
|
|
if len(man.HDDSubdirs) != 1 || man.HDDSubdirs[0] != "userdata" {
|
|
t.Errorf("manifest must list the single v1 `userdata` basename, got %v", man.HDDSubdirs)
|
|
}
|
|
}
|
|
|
|
// Scenario A at the export level: a legacy (no-block) app tars the FULL userdata root (every sibling)
|
|
// — byte-identical to v0.130.0 (the SQ5 safety net).
|
|
func TestFabExport_LegacyFullRoot(t *testing.T) {
|
|
drive := t.TempDir()
|
|
fabWrite(t, drive, "userdata/media/books/a.epub", "BOOK")
|
|
fabWrite(t, drive, "userdata/media/movies/big.mkv", "MOVIE")
|
|
|
|
ud := appbackup.UserdataDir(filepath.Clean(drive))
|
|
prov := &fabProv{rtProvider: &rtProvider{}, hddPath: drive, has: false, mounts: []string{ud}}
|
|
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
|
|
|
|
dataDir := t.TempDir()
|
|
man := &Manifest{}
|
|
if err := e.exportHDDData(ExportRequest{StackName: "sonarr"}, dataDir, man); err != nil {
|
|
t.Fatalf("exportHDDData: %v", err)
|
|
}
|
|
entries := tarEntries(t, filepath.Join(dataDir, "hdd", "userdata.tar"))
|
|
for _, want := range []string{"media/books/a.epub", "media/movies/big.mkv"} {
|
|
if !containsSuffix(entries, want) {
|
|
t.Errorf("legacy app must capture the FULL root — %q missing: %v", want, entries)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scenario B at the export level: an all-excluded app produces NO userdata.tar (root tar skipped).
|
|
func TestFabExport_AllExcludedNoUserdataTar(t *testing.T) {
|
|
drive := t.TempDir()
|
|
fabWrite(t, drive, "userdata/media/movies/big.mkv", "MOVIE")
|
|
ud := appbackup.UserdataDir(filepath.Clean(drive))
|
|
prov := &fabProv{
|
|
rtProvider: &rtProvider{}, hddPath: drive, has: true,
|
|
binds: []appbackup.ClassifiedBind{xUD("media/movies")},
|
|
mounts: []string{ud},
|
|
}
|
|
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
|
|
dataDir := t.TempDir()
|
|
man := &Manifest{}
|
|
if err := e.exportHDDData(ExportRequest{StackName: "radarr"}, dataDir, man); err != nil {
|
|
t.Fatalf("exportHDDData: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dataDir, "hdd", "userdata.tar")); !os.IsNotExist(err) {
|
|
t.Errorf("all-excluded app must produce NO userdata.tar (Scenario B), stat err=%v", err)
|
|
}
|
|
if len(man.HDDSubdirs) != 0 {
|
|
t.Errorf("no userdata leg → no manifest subdir, got %v", man.HDDSubdirs)
|
|
}
|
|
}
|
|
|
|
// §7-F: EstimateExport populates the class split for a classified app (both web estimate pipelines
|
|
// call this shared function, so both surface it). du returns 0 on the Windows test host, so this
|
|
// asserts the STRUCTURE (HasClassification + item keys), not byte values.
|
|
func TestEstimateExport_ClassifiedSplit(t *testing.T) {
|
|
drive := t.TempDir()
|
|
stackDir := t.TempDir()
|
|
os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0644)
|
|
prov := &fabProv{
|
|
rtProvider: &rtProvider{stackDir: stackDir, deployed: true}, hddPath: drive, has: true,
|
|
binds: []appbackup.ClassifiedBind{mUD("media/books"), oUD("media/comics"), xUD("media/movies")},
|
|
}
|
|
e := NewExporter(prov, log.New(io.Discard, "", 0), "test")
|
|
est, err := e.EstimateExport("calibre-web", drive)
|
|
if err != nil {
|
|
t.Fatalf("EstimateExport: %v", err)
|
|
}
|
|
if !est.HasClassification {
|
|
t.Fatal("classified app estimate must carry HasClassification")
|
|
}
|
|
if len(est.MandatoryItems) != 1 || est.MandatoryItems[0].Key != "userdata/media/books" {
|
|
t.Errorf("MandatoryItems = %+v", est.MandatoryItems)
|
|
}
|
|
if len(est.OptionalItems) != 1 || est.OptionalItems[0].Key != "userdata/media/comics" {
|
|
t.Errorf("OptionalItems = %+v", est.OptionalItems)
|
|
}
|
|
if len(est.ExcludedItems) != 1 || est.ExcludedItems[0].Key != "userdata/media/movies" {
|
|
t.Errorf("ExcludedItems = %+v", est.ExcludedItems)
|
|
}
|
|
}
|