.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:
2026-07-15 11:28:40 +02:00
parent 5f216138e5
commit cf9ce01917
19 changed files with 1392 additions and 177 deletions
+27
View File
@@ -27,6 +27,27 @@ type ExportEstimate struct {
// failed). When true, DataSizeBytes is a partial/understated sum and FitsOnDest is FORCED false
// — a failed read must NEVER render as "fits". The UI shows "ismeretlen méret".
SizeUnknown bool `json:"size_unknown"`
// Task 4 class split (classified apps only; empty for legacy — existing fields above are
// unchanged, so old JSON consumers keep working). BaseBytes = config + DB + volumes + mandatory
// (always in the bundle). OptionalItems are pre-selected, ExcludedItems are opt-in — each carries
// its own size so the UI recomputes the total client-side per checkbox toggle (no extra du calls).
HasClassification bool `json:"has_classification"`
BaseBytes int64 `json:"base_bytes"`
BaseHuman string `json:"base_human"`
MandatoryItems []FabItem `json:"mandatory_items,omitempty"`
OptionalItems []FabItem `json:"optional_items,omitempty"`
ExcludedItems []FabItem `json:"excluded_items,omitempty"`
}
// FabItem is one class-scoped path in the `.fab` selection UI: Key is the DeselectOptional/OptInExcluded
// value ("root/rel"), RelPath is the display path, Bytes/Human its du size.
type FabItem struct {
Key string `json:"key"`
Root string `json:"root"`
RelPath string `json:"rel_path"`
Bytes int64 `json:"bytes"`
Human string `json:"human"`
}
// EstimateExport calculates size estimates for an app export.
@@ -57,6 +78,7 @@ func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate,
}
volumes := e.provider.GetDockerVolumes(stackName)
e.debugf("EstimateExport: Docker volumes: %v", volumes)
var volumeBytes int64
for _, vol := range volumes {
volSize, err := volumeSizer(vol)
if err != nil {
@@ -68,6 +90,7 @@ func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate,
}
e.debugf("EstimateExport: volume %s = %s", vol, humanizeBytes(volSize))
est.DataSizeBytes += volSize
volumeBytes += volSize
}
if est.SizeUnknown {
est.DataSizeHuman = "ismeretlen méret"
@@ -78,6 +101,10 @@ func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate,
est.TotalSizeBytes = est.ConfigSizeBytes + est.DataSizeBytes
est.TotalSizeHuman = humanizeBytes(est.TotalSizeBytes)
// Task 4: the class split (classified apps only). Independent du over each bucket path — additive,
// never touches the fields/fits gate above.
e.fabEstimateSplit(stackName, est, volumeBytes)
// Rough time estimate: ~500 MB/min for HDDs, minimum 1 minute
minutes := int(est.TotalSizeBytes / (500 * 1024 * 1024))
if minutes < 1 {
@@ -6,19 +6,26 @@ import (
"log"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
// hddProvider is an rtProvider that reports an HDD-backed stack (estimate scenario H + the
// v0.130.0 additive-export tests in export_additive_test.go).
type hddProvider struct {
*rtProvider
mounts []string
hddPath string
mounts []string
hddPath string
binds []appbackup.ClassifiedBind
hasBinds bool
}
func (p *hddProvider) GetStackNeedsHDD(string) bool { return true }
func (p *hddProvider) GetStackHDDMounts(string) []string { return p.mounts }
func (p *hddProvider) GetStackHDDPath(string) string { return p.hddPath }
func (p *hddProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
return p.binds, p.hasBinds
}
func newEstimator(t *testing.T, provider ExportStackProvider) *Exporter {
t.Helper()
+38 -48
View File
@@ -75,6 +75,14 @@ type ExportRequest struct {
DestDrive string // drive mount path (e.g., "/mnt/hdd_1")
Password string // empty = no encryption
StopApp bool // stop app before export
// `.fab` class-scoped selection (Task 4; classified apps only — legacy apps ignore these). Both
// empty = ruling #1 defaults (mandatory in, optional in, excluded out). Values are "root/rel" keys
// (matching a CapturePath: "hdd/appdata/x" | "userdata/media/y"). The server enforces the floor:
// a DeselectOptional entry naming a MANDATORY path is ignored with a WARN (the client cannot weaken
// the mandatory floor). OptInExcluded pulls an excluded bind into the bundle.
DeselectOptional []string
OptInExcluded []string
}
// Exporter manages app export/import operations.
@@ -84,6 +92,10 @@ type Exporter struct {
version string
debug bool
// dirLister (Task 4) lists child DIR names of a path — the seam the `.fab` userdata-exclude
// computation walks. Nil → the real os.ReadDir-based lister.
dirLister func(dir string) []string
mu sync.Mutex
activeJob *Job
}
@@ -330,7 +342,7 @@ func (e *Exporter) executeExport(req ExportRequest, job *Job) {
// dropped every named volume of every needs_hdd app.
if e.provider.GetStackNeedsHDD(req.StackName) {
e.debugf("exporting HDD data for %s", req.StackName)
if err := e.exportHDDData(req.StackName, dataDir, manifest); err != nil {
if err := e.exportHDDData(req, dataDir, manifest); err != nil {
e.failJob(job, step, fmt.Sprintf("Felhasználói adatok mentése sikertelen: %v", err))
return
}
@@ -591,7 +603,8 @@ func (e *Exporter) dumpDatabase(stackName, dbDir string, manifest *Manifest) boo
// collision between two mounts is a FATAL error (the manifest keys tars by basename; the old
// code silently overwrote the first tar). A non-existent mount is still soft-skipped (honestly
// absent from the manifest — the anti-hollow guard catches total emptiness).
func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest) error {
func (e *Exporter) exportHDDData(req ExportRequest, dataDir string, manifest *Manifest) error {
stackName := req.StackName
hddDir := filepath.Join(dataDir, "hdd")
os.MkdirAll(hddDir, 0755)
@@ -602,8 +615,22 @@ func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest)
return nil
}
// Task 4: the class-scoped plan. Legacy / no-block apps get an EMPTY plan (all mounts kept, root
// tar with zero excludes) → byte-identical v0.130.0 capture.
plan := e.computeFabPlan(req, mounts)
ud := appbackup.UserdataDir(filepath.Clean(e.provider.GetStackHDDPath(stackName)))
claimed := make(map[string]string) // subdir → mount that claimed it
for _, mount := range mounts {
if plan.SkipMounts[filepath.Clean(mount)] {
e.debugf("HDD mount %s skipped — not selected (class-scoped plan)", mount)
continue
}
isUserdataRoot := filepath.Clean(mount) == filepath.Clean(ud)
if isUserdataRoot && plan.SkipUserdataTar {
e.debugf("userdata root %s skipped — no selected userdata bind (Scenario B)", mount)
continue
}
if _, err := os.Stat(mount); os.IsNotExist(err) {
e.debugf("HDD mount %s does not exist — skipping", mount)
continue
@@ -620,9 +647,13 @@ func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest)
claimed[subdir] = mount
tarPath := filepath.Join(hddDir, subdir+".tar")
e.debugf("tarring HDD mount: %s → %s", mount, tarPath)
var excludes []string
if isUserdataRoot {
excludes = plan.UserdataExcludeRels // R1-C: exclude-scoped root tar (empty for legacy)
}
e.debugf("tarring HDD mount: %s → %s (%d exclude(s))", mount, tarPath, len(excludes))
tarStart := time.Now()
if err := tarDirectory(mount, tarPath); err != nil {
if err := tarDirectoryExcluding(mount, tarPath, excludes); err != nil {
// v0.125.0: claim the subdir ONLY on success — a claimed-but-absent tar would trip
// the packaging assertion; an honestly-skipped mount stays out of the manifest.
e.logger.Printf("[WARN] Export: failed to tar %s (excluded from the bundle): %v", mount, err)
@@ -817,51 +848,10 @@ func createTarGz(outputPath, sourceDir string) error {
})
}
// tarDirectory creates a tar (not gzipped) of a directory's contents.
// tarDirectory creates a tar (not gzipped) of a directory's contents. Thin wrapper over
// tarDirectoryExcluding (Task 4) with no excludes — its existing callers are unchanged.
func tarDirectory(sourceDir, outputPath string) error {
outFile, err := os.Create(outputPath)
if err != nil {
return err
}
defer outFile.Close()
tw := tar.NewWriter(outFile)
defer tw.Close()
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = relPath
if err := tw.WriteHeader(header); err != nil {
return err
}
if info.IsDir() {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
})
return tarDirectoryExcluding(sourceDir, outputPath, nil)
}
// gzipFile compresses a file with gzip.
@@ -0,0 +1,138 @@
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)
}
}
+304
View File
@@ -0,0 +1,304 @@
package appexport
import (
"archive/tar"
"io"
"os"
"path/filepath"
"sort"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)
// `.fab` class-scoped export plan (Task 4, architecture §2 `.fab` row + SQ5 exclusion-scoping verdict,
// R1-C). Mechanics unchanged from v0.130.0: ONE exclude-scoped userdata-root tar + per-mount skip for
// non-selected HDD binds. The manifest stays v1 (basename keying) and the import side is untouched.
// fabPlan is the class-scoped adjustment to the v0.130.0 mount/tar set. Empty (all zero) = the legacy
// full capture (Scenario A — a no-block app produces this).
type fabPlan struct {
SkipMounts map[string]bool // absolute HDD mount paths to skip entirely
SkipUserdataTar bool // no selected userdata bind ⇒ the whole root tar is skipped (Scenario B)
UserdataExcludeRels []string // rels (relative to the userdata root) excluded from its tar (R1-C, Scenario C)
}
func relKey(root appbackup.BindRoot, rel string) string { return string(root) + "/" + rel }
func relKeyOf(cp appbackup.CapturePath) string { return relKey(cp.Root, cp.RelPath) }
// computeFabPlan resolves the class buckets + the caller's selection into the mount/userdata plan
// (§8). Legacy / no-block ⇒ empty plan. The mandatory floor is enforced here: DeselectOptional can
// never drop a mandatory path.
func (e *Exporter) computeFabPlan(req ExportRequest, mounts []string) fabPlan {
binds, has := e.provider.GetStackClassifiedBinds(req.StackName)
if !has {
return fabPlan{} // legacy: byte-identical v0.130.0 capture
}
hddPath := filepath.Clean(e.provider.GetStackHDDPath(req.StackName))
fb := appbackup.ComputeFabBuckets(binds, has, hddPath)
deselect := sliceSet(req.DeselectOptional)
optIn := sliceSet(req.OptInExcluded)
// Server-side floor: a request naming a mandatory path in DeselectOptional is ignored (loud WARN).
for _, cp := range fb.Mandatory {
if deselect[relKeyOf(cp)] {
e.logger.Printf("[WARN] appexport: %s: request tried to deselect a MANDATORY path %s — ignored (floor enforced)", req.StackName, relKeyOf(cp))
}
}
// Resolve the selected set (mandatory always; optional default-in; excluded default-out).
var selectedHDD, selectedUD []appbackup.CapturePath
add := func(cp appbackup.CapturePath) {
if cp.Root == appbackup.RootUserdata {
selectedUD = append(selectedUD, cp)
} else {
selectedHDD = append(selectedHDD, cp)
}
}
for _, cp := range fb.Mandatory {
add(cp)
}
for _, cp := range fb.Optional {
if !deselect[relKeyOf(cp)] {
add(cp)
}
}
for _, cp := range fb.Excluded {
if optIn[relKeyOf(cp)] {
add(cp)
}
}
// Every classified HDD bind (any class) — a mount matching NONE of these is "unclassified" and kept
// (fail toward capture, the C6B-F1 direction).
var classifiedHDD []string
for _, bucket := range [][]appbackup.CapturePath{fb.Mandatory, fb.Optional, fb.Excluded} {
for _, cp := range bucket {
if cp.Root == appbackup.RootHDD {
classifiedHDD = append(classifiedHDD, cp.Abs)
}
}
}
plan := fabPlan{SkipMounts: map[string]bool{}}
ud := appbackup.UserdataDir(hddPath)
for _, m := range mounts {
mc := filepath.Clean(m)
if mc == filepath.Clean(ud) {
if len(selectedUD) == 0 {
plan.SkipUserdataTar = true // Scenario B: no selected userdata bind → no root tar
}
continue
}
// HDD mount. Unmatched by ANY classified bind → keep (fail toward capture), log it.
if !relatedToAny(mc, classifiedHDD) {
e.logger.Printf("[INFO] appexport: %s: HDD mount %s matches no classified bind — kept (fail toward capture)", req.StackName, mc)
continue
}
// Matched a classified bind: keep iff ancestor-or-descendant of a SELECTED HDD path.
if !relatedToAny(mc, absList(selectedHDD)) {
plan.SkipMounts[mc] = true
}
}
if !plan.SkipUserdataTar && len(selectedUD) > 0 {
plan.UserdataExcludeRels = e.fabUserdataExcludes(ud, udRels(selectedUD))
}
return plan
}
// fabEstimateSplit populates the class-split estimate fields for a classified app (Task 4). Legacy /
// no-block apps leave HasClassification=false (the UI shows the plain estimate). BaseBytes = config +
// volumes + mandatory; optional/excluded carry per-path sizes for client-side total recomputation.
func (e *Exporter) fabEstimateSplit(stackName string, est *ExportEstimate, volumeBytes int64) {
binds, has := e.provider.GetStackClassifiedBinds(stackName)
if !has {
return
}
hddPath := filepath.Clean(e.provider.GetStackHDDPath(stackName))
fb := appbackup.ComputeFabBuckets(binds, has, hddPath)
est.HasClassification = true
toItems := func(cps []appbackup.CapturePath) ([]FabItem, int64) {
var items []FabItem
var sum int64
for _, cp := range cps {
sz := duBytes(cp.Abs)
items = append(items, FabItem{Key: relKeyOf(cp), Root: string(cp.Root), RelPath: cp.RelPath, Bytes: sz, Human: humanizeBytes(sz)})
sum += sz
}
return items, sum
}
var mandSum int64
est.MandatoryItems, mandSum = toItems(fb.Mandatory)
est.OptionalItems, _ = toItems(fb.Optional)
est.ExcludedItems, _ = toItems(fb.Excluded)
est.BaseBytes = est.ConfigSizeBytes + volumeBytes + mandSum
est.BaseHuman = humanizeBytes(est.BaseBytes)
}
func sliceSet(ss []string) map[string]bool {
m := make(map[string]bool, len(ss))
for _, s := range ss {
m[s] = true
}
return m
}
func absList(cps []appbackup.CapturePath) []string {
out := make([]string, len(cps))
for i, cp := range cps {
out[i] = cp.Abs
}
return out
}
// udRels returns the userdata rels (relative to ${USERDATA_PATH}) of selected userdata paths.
func udRels(cps []appbackup.CapturePath) []string {
out := make([]string, 0, len(cps))
for _, cp := range cps {
out = append(out, cp.RelPath)
}
return out
}
// relatedToAny reports whether path p is an ancestor OR descendant (or equal) of any path in set.
func relatedToAny(p string, set []string) bool {
pc := filepath.Clean(p)
for _, s := range set {
sc := filepath.Clean(s)
if pc == sc || strings.HasPrefix(pc, sc+string(filepath.Separator)) || strings.HasPrefix(sc, pc+string(filepath.Separator)) {
return true
}
}
return false
}
// fabRelClass classifies a dir rel (slash-form, relative to the userdata root) against the selected
// userdata rels — the R1-C keep-rule (mirrors backup.classifyTier2Rel, copied not imported):
// keepInside = a selected rel or inside one (keep, don't descend); keepAncestor = on the path to a
// selected rel (keep, descend); else stale (exclude the topmost).
type fabRelClass int
const (
fabStale fabRelClass = iota
fabKeepInside
fabKeepAncestor
)
func classifyFabRel(dirRel string, selectedRels []string) fabRelClass {
for _, sr := range selectedRels {
if dirRel == sr || strings.HasPrefix(dirRel, sr+"/") {
return fabKeepInside
}
}
for _, sr := range selectedRels {
if strings.HasPrefix(sr, dirRel+"/") {
return fabKeepAncestor
}
}
return fabStale
}
// fabUserdataExcludes walks the userdata root (via the dirLister seam) and returns the topmost rels
// (relative to the root, slash-form) that are neither an ancestor nor a descendant of a selected
// userdata rel — the exclude list for the root tar (R1-C). Deterministic (sorted).
func (e *Exporter) fabUserdataExcludes(udRoot string, selectedRels []string) []string {
lister := e.dirLister
if lister == nil {
lister = realDirLister
}
var excludes []string
var walk func(dirAbs, dirRel string)
walk = func(dirAbs, dirRel string) {
for _, name := range lister(dirAbs) {
childRel := name
if dirRel != "" {
childRel = dirRel + "/" + name
}
switch classifyFabRel(childRel, selectedRels) {
case fabKeepInside:
// selected leg or content inside it — keep, no descent
case fabKeepAncestor:
walk(filepath.Join(dirAbs, name), childRel)
default:
excludes = append(excludes, childRel) // topmost neither-ancestor-nor-descendant
}
}
}
walk(udRoot, "")
sort.Strings(excludes)
return excludes
}
func realDirLister(dir string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var names []string
for _, en := range entries {
if en.IsDir() {
names = append(names, en.Name())
}
}
return names
}
// tarDirectoryExcluding is tarDirectory with an exclude list: any path whose rel (relative to
// sourceDir, slash-form) equals or descends from an exclude rel is skipped (a dir is pruned whole).
// Empty excludes == tarDirectory. Anchored to the tar root exactly like tarDirectory's rel names.
func tarDirectoryExcluding(sourceDir, outputPath string, excludeRels []string) error {
excl := make([]string, len(excludeRels))
for i, r := range excludeRels {
excl[i] = filepath.ToSlash(r)
}
outFile, err := os.Create(outputPath)
if err != nil {
return err
}
defer outFile.Close()
tw := tar.NewWriter(outFile)
defer tw.Close()
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
rel := filepath.ToSlash(relPath)
for _, e := range excl {
if rel == e || strings.HasPrefix(rel, e+"/") {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = relPath
if err := tw.WriteHeader(header); err != nil {
return err
}
if info.IsDir() {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
})
}
@@ -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
}
@@ -4,6 +4,8 @@
// the app to its current state.
package appexport
import "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
// ExportStackProvider provides stack data without circular imports.
// Implemented by exportAdapter in main.go (same pattern as backup.StackDataProvider).
type ExportStackProvider interface {
@@ -15,6 +17,10 @@ type ExportStackProvider interface {
GetStackHDDMounts(name string) []string
// GetStackHDDPath returns the raw HDD_PATH env var from app.yaml.
GetStackHDDPath(name string) string
// GetStackClassifiedBinds returns the app's backup-classified compose binds + whether it carries a
// (valid) backup block (Task 2). Drives the `.fab` class-scoped export plan (Task 4); a legacy app
// (false) exports the v0.130.0 full-root capture unchanged.
GetStackClassifiedBinds(name string) ([]appbackup.ClassifiedBind, bool)
// IsStackRunning returns true if the stack has running containers.
IsStackRunning(name string) bool
// StopStack stops the stack via docker compose down.
@@ -1,6 +1,7 @@
package appexport
import (
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"io"
"log"
"os"
@@ -36,6 +37,9 @@ func (p *rtProvider) GetStackComposePath(string) (string, bool) {
}
func (p *rtProvider) GetStackHDDMounts(string) []string { return nil }
func (p *rtProvider) GetStackHDDPath(string) string { return "" }
func (p *rtProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
return nil, false
}
func (p *rtProvider) IsStackRunning(string) bool { return p.running }
func (p *rtProvider) StopStack(string) error { p.stopped++; return nil }
func (p *rtProvider) StartStack(string) error { p.started = true; return nil }