.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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user