.fab exclusion scoping: classes in the manual export (Task 4, v0.136.0)
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.
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user