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.
266 lines
9.8 KiB
Go
266 lines
9.8 KiB
Go
package appexport
|
|
|
|
import (
|
|
"archive/tar"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
)
|
|
|
|
// fabProv is a minimal ExportStackProvider for the plan tests: configurable classified binds + hddPath.
|
|
type fabProv struct {
|
|
*rtProvider
|
|
hddPath string
|
|
binds []appbackup.ClassifiedBind
|
|
has bool
|
|
mounts []string
|
|
}
|
|
|
|
func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath }
|
|
func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts }
|
|
func (p *fabProv) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
|
return p.binds, p.has
|
|
}
|
|
|
|
func newFabExporter(binds []appbackup.ClassifiedBind, has bool, hddPath string, tree map[string][]string) *Exporter {
|
|
e := NewExporter(&fabProv{rtProvider: &rtProvider{}, hddPath: hddPath, binds: binds, has: has}, log.New(io.Discard, "", 0), "test")
|
|
e.dirLister = func(dir string) []string { return tree[dir] }
|
|
return e
|
|
}
|
|
|
|
func mHDD(rel string) appbackup.ClassifiedBind {
|
|
return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassMandatory}
|
|
}
|
|
func mUD(rel string) appbackup.ClassifiedBind {
|
|
return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel}, Class: appbackup.ClassMandatory}
|
|
}
|
|
func oUD(rel string) appbackup.ClassifiedBind {
|
|
return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel}, Class: appbackup.ClassOptional}
|
|
}
|
|
func xUD(rel string) appbackup.ClassifiedBind {
|
|
return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel}, Class: appbackup.ClassExcluded}
|
|
}
|
|
|
|
const hp = "/srv/data" // hddPath (plan is string-only; the lister is injected)
|
|
|
|
// udDir builds an OS-native userdata dir path (matches appbackup.UserdataDir + the walk's
|
|
// filepath.Join, so injected-lister keys line up on any host).
|
|
func udDir(rel ...string) string {
|
|
return filepath.Join(append([]string{hp, "userdata"}, rel...)...)
|
|
}
|
|
|
|
// A — legacy app: empty plan (byte-identical v0.130.0 capture).
|
|
func TestFabPlan_LegacyEmpty(t *testing.T) {
|
|
e := newFabExporter(nil, false, hp, nil)
|
|
mounts := []string{hp + "/appdata/app", hp + "/userdata"}
|
|
plan := e.computeFabPlan(ExportRequest{StackName: "x"}, mounts)
|
|
if len(plan.SkipMounts) != 0 || plan.SkipUserdataTar || plan.UserdataExcludeRels != nil {
|
|
t.Errorf("legacy app must yield an EMPTY plan (every mount kept, no excludes), got %+v", plan)
|
|
}
|
|
}
|
|
|
|
// B — all-excluded app: the userdata root tar is skipped entirely.
|
|
func TestFabPlan_AllExcludedSkipsUserdataTar(t *testing.T) {
|
|
binds := []appbackup.ClassifiedBind{xUD("media/movies"), xUD("downloads")}
|
|
e := newFabExporter(binds, true, hp, map[string][]string{"/srv/data/userdata": {"media", "downloads"}})
|
|
plan := e.computeFabPlan(ExportRequest{StackName: "radarr"}, []string{hp + "/userdata"})
|
|
if !plan.SkipUserdataTar {
|
|
t.Error("no selected userdata bind → the whole root tar must be skipped (Scenario B)")
|
|
}
|
|
}
|
|
|
|
// C — selected-complement: media/books kept, siblings excluded (R1-C).
|
|
func TestFabPlan_SelectedComplement(t *testing.T) {
|
|
binds := []appbackup.ClassifiedBind{mUD("media/books"), xUD("media/movies")}
|
|
tree := map[string][]string{
|
|
udDir(): {"media", "music"},
|
|
udDir("media"): {"books", "movies", "comics"},
|
|
}
|
|
e := newFabExporter(binds, true, hp, tree)
|
|
plan := e.computeFabPlan(ExportRequest{StackName: "calibre-web"}, []string{hp + "/userdata"})
|
|
if plan.SkipUserdataTar {
|
|
t.Fatal("a selected userdata bind exists — the root tar must NOT be skipped")
|
|
}
|
|
want := []string{"media/comics", "media/movies", "music"}
|
|
if got := plan.UserdataExcludeRels; !reflect.DeepEqual(got, want) {
|
|
t.Errorf("excludes = %v, want %v (media/books kept, siblings excluded)", got, want)
|
|
}
|
|
}
|
|
|
|
// D — optional default-in / uncheck-out / excluded opt-in.
|
|
func TestFabPlan_OptionalAndOptIn(t *testing.T) {
|
|
// A mandatory anchor (data) keeps the root tar always produced, so comics/podcasts inclusion is
|
|
// exercised via the EXCLUDE LIST (not the whole-tar skip).
|
|
binds := []appbackup.ClassifiedBind{mUD("data"), oUD("media/comics"), xUD("media/podcasts")}
|
|
tree := map[string][]string{
|
|
udDir(): {"data", "media"},
|
|
udDir("media"): {"comics", "podcasts", "junk"},
|
|
}
|
|
// D1 default: optional comics IN → not excluded; podcasts (excluded) + junk (unselected) excluded.
|
|
e := newFabExporter(binds, true, hp, tree)
|
|
p1 := e.computeFabPlan(ExportRequest{StackName: "komga"}, []string{hp + "/userdata"})
|
|
if p1.SkipUserdataTar {
|
|
t.Fatal("mandatory anchor selected — tar must be produced")
|
|
}
|
|
if effExcluded(p1.UserdataExcludeRels, "media/comics") {
|
|
t.Error("D1: default → optional comics must be INCLUDED (not excluded)")
|
|
}
|
|
if !effExcluded(p1.UserdataExcludeRels, "media/podcasts") {
|
|
t.Error("D1: excluded podcasts must be excluded by default")
|
|
}
|
|
// D2 uncheck the optional → excluded (effectively, via a topmost exclude covering it).
|
|
p2 := e.computeFabPlan(ExportRequest{StackName: "komga", DeselectOptional: []string{"userdata/media/comics"}}, []string{hp + "/userdata"})
|
|
if !effExcluded(p2.UserdataExcludeRels, "media/comics") {
|
|
t.Errorf("D2: unchecked optional must be excluded, excludes=%v", p2.UserdataExcludeRels)
|
|
}
|
|
// D3 opt-in the excluded → included.
|
|
p3 := e.computeFabPlan(ExportRequest{StackName: "komga", OptInExcluded: []string{"userdata/media/podcasts"}}, []string{hp + "/userdata"})
|
|
if effExcluded(p3.UserdataExcludeRels, "media/podcasts") {
|
|
t.Error("D3: opted-in excluded must be INCLUDED (not excluded)")
|
|
}
|
|
}
|
|
|
|
// effExcluded reports whether rel (or an ancestor of it) is in the topmost exclude list.
|
|
func effExcluded(excludes []string, rel string) bool {
|
|
for _, e := range excludes {
|
|
if rel == e || len(rel) > len(e) && rel[:len(e)+1] == e+"/" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// D floor — a request deselecting a MANDATORY path is IGNORED (mandatory stays in).
|
|
func TestFabPlan_MandatoryFloor(t *testing.T) {
|
|
binds := []appbackup.ClassifiedBind{mUD("media/books")}
|
|
tree := map[string][]string{udDir(): {"media"}, udDir("media"): {"books"}}
|
|
e := newFabExporter(binds, true, hp, tree)
|
|
// client tries to deselect the mandatory path — must be ignored (books NOT excluded).
|
|
plan := e.computeFabPlan(ExportRequest{StackName: "x", DeselectOptional: []string{"userdata/media/books"}}, []string{hp + "/userdata"})
|
|
if contains(plan.UserdataExcludeRels, "media/books") || plan.SkipUserdataTar {
|
|
t.Errorf("mandatory floor breached — media/books must stay in the bundle; plan=%+v", plan)
|
|
}
|
|
}
|
|
|
|
// §8 — an HDD mount matching NO classified bind is KEPT (fail toward capture).
|
|
func TestFabPlan_UnmatchedMountKept(t *testing.T) {
|
|
binds := []appbackup.ClassifiedBind{mHDD("appdata/known")}
|
|
e := newFabExporter(binds, true, hp, nil)
|
|
mounts := []string{hp + "/appdata/known", hp + "/appdata/mystery"}
|
|
plan := e.computeFabPlan(ExportRequest{StackName: "x"}, mounts)
|
|
if plan.SkipMounts[filepath.Clean(hp+"/appdata/mystery")] {
|
|
t.Error("an unmatched mount must be KEPT (fail toward capture, C6B-F1)")
|
|
}
|
|
if plan.SkipMounts[filepath.Clean(hp+"/appdata/known")] {
|
|
t.Error("a mandatory-matched mount must be kept")
|
|
}
|
|
}
|
|
|
|
// §8 — a classified HDD mount that is NOT selected is skipped.
|
|
func TestFabPlan_UnselectedHDDMountSkipped(t *testing.T) {
|
|
binds := []appbackup.ClassifiedBind{xHDD("appdata/cache")}
|
|
e := newFabExporter(binds, true, hp, nil)
|
|
mounts := []string{hp + "/appdata/cache"}
|
|
plan := e.computeFabPlan(ExportRequest{StackName: "x"}, mounts)
|
|
if !plan.SkipMounts[filepath.Clean(hp+"/appdata/cache")] {
|
|
t.Error("an excluded, un-opted-in HDD mount must be skipped")
|
|
}
|
|
}
|
|
|
|
func xHDD(rel string) appbackup.ClassifiedBind {
|
|
return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassExcluded}
|
|
}
|
|
|
|
// classifyFabRel keep-rule truth table (the R1-C core, pure).
|
|
func TestClassifyFabRel(t *testing.T) {
|
|
sel := []string{"media/books"}
|
|
cases := []struct {
|
|
rel string
|
|
want fabRelClass
|
|
}{
|
|
{"media/books", fabKeepInside},
|
|
{"media/books/covers", fabKeepInside},
|
|
{"media", fabKeepAncestor},
|
|
{"media/movies", fabStale},
|
|
{"music", fabStale},
|
|
}
|
|
for _, c := range cases {
|
|
if got := classifyFabRel(c.rel, sel); got != c.want {
|
|
t.Errorf("classifyFabRel(%q) = %d, want %d", c.rel, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// tarDirectoryExcluding FS-level: excluded subtrees are absent, kept content present.
|
|
func TestTarDirectoryExcluding(t *testing.T) {
|
|
src := t.TempDir()
|
|
write := func(rel, content string) {
|
|
p := filepath.Join(src, filepath.FromSlash(rel))
|
|
os.MkdirAll(filepath.Dir(p), 0755)
|
|
os.WriteFile(p, []byte(content), 0644)
|
|
}
|
|
write("media/books/a.epub", "BOOK")
|
|
write("media/movies/big.mkv", "MOVIE")
|
|
write("music/song.flac", "SONG")
|
|
|
|
out := filepath.Join(t.TempDir(), "userdata.tar")
|
|
if err := tarDirectoryExcluding(src, out, []string{"media/movies", "music"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := tarEntries(t, out)
|
|
if !containsSuffix(got, "media/books/a.epub") {
|
|
t.Errorf("kept content missing: %v", got)
|
|
}
|
|
for _, bad := range []string{"media/movies/big.mkv", "music/song.flac", "media/movies", "music"} {
|
|
if containsSuffix(got, bad) {
|
|
t.Errorf("excluded path %q present in tar: %v", bad, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func tarEntries(t *testing.T, tarPath string) []string {
|
|
t.Helper()
|
|
f, err := os.Open(tarPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer f.Close()
|
|
tr := tar.NewReader(f)
|
|
var names []string
|
|
for {
|
|
h, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
names = append(names, filepath.ToSlash(h.Name))
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func contains(ss []string, want string) bool {
|
|
for _, s := range ss {
|
|
if s == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func containsSuffix(ss []string, suffix string) bool {
|
|
for _, s := range ss {
|
|
if s == suffix || filepath.ToSlash(s) == suffix {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|