.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:
@@ -79,37 +79,9 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt
|
||||
}
|
||||
cs := CaptureSet{HasClassification: true}
|
||||
|
||||
// Stage 1+2: tier filter, then structural guards. Survivors are resolved to CapturePath.
|
||||
var resolved []CapturePath
|
||||
for _, b := range binds {
|
||||
if !tierKeeps(tier, b.Class) {
|
||||
continue // excluded (any tier) or optional@offsite — silently filtered, not skipped
|
||||
}
|
||||
if reason, bad := structuralGuard(b.Root, b.RelPath); bad {
|
||||
cs.Skipped = append(cs.Skipped, SkippedPath{
|
||||
Root: b.Root, RelPath: b.RelPath, Class: b.Class, Reason: reason,
|
||||
})
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, CapturePath{
|
||||
Abs: resolveAbs(hddPath, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
|
||||
})
|
||||
}
|
||||
|
||||
// Stage 3: equal-Abs collapse — one entry per Abs, mandatory beats optional (mandatory semantics
|
||||
// must never degrade). Deterministic on ties: keep the lexicographically-smaller (Root, RelPath).
|
||||
byAbs := make(map[string]CapturePath, len(resolved))
|
||||
for _, cp := range resolved {
|
||||
if cur, ok := byAbs[cp.Abs]; ok {
|
||||
byAbs[cp.Abs] = strongerCapture(cur, cp)
|
||||
continue
|
||||
}
|
||||
byAbs[cp.Abs] = cp
|
||||
}
|
||||
uniq := make([]CapturePath, 0, len(byAbs))
|
||||
for _, cp := range byAbs {
|
||||
uniq = append(uniq, cp)
|
||||
}
|
||||
// Stages 1–3: tier filter → structural guards → equal-Abs collapse (shared with ComputeFabBuckets).
|
||||
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(c BindClass) bool { return tierKeeps(tier, c) })
|
||||
cs.Skipped = skipped
|
||||
|
||||
// Stage 4: containment dedup — drop any path whose ancestor is already present (keep the ancestor).
|
||||
for _, cp := range uniq {
|
||||
@@ -130,6 +102,87 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt
|
||||
return cs
|
||||
}
|
||||
|
||||
// resolveGuardCollapse is the pipeline shared by ComputeCaptureSet and ComputeFabBuckets: keep-filter
|
||||
// (the caller's predicate over class) → structural guards (Skipped) → resolve to Abs → equal-Abs
|
||||
// collapse (mandatory > optional > excluded; ties by smaller Root/RelPath). It does NOT apply
|
||||
// containment dedup — the caller decides (ComputeCaptureSet does; ComputeFabBuckets must not, so a
|
||||
// mandatory child inside an excluded parent stays independently addressable).
|
||||
func resolveGuardCollapse(binds []ClassifiedBind, hddPath string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) {
|
||||
var resolved []CapturePath
|
||||
for _, b := range binds {
|
||||
if !keep(b.Class) {
|
||||
continue
|
||||
}
|
||||
if reason, bad := structuralGuard(b.Root, b.RelPath); bad {
|
||||
skipped = append(skipped, SkippedPath{Root: b.Root, RelPath: b.RelPath, Class: b.Class, Reason: reason})
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, CapturePath{
|
||||
Abs: resolveAbs(hddPath, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class,
|
||||
})
|
||||
}
|
||||
byAbs := make(map[string]CapturePath, len(resolved))
|
||||
for _, cp := range resolved {
|
||||
if cur, ok := byAbs[cp.Abs]; ok {
|
||||
byAbs[cp.Abs] = strongerCapture(cur, cp)
|
||||
continue
|
||||
}
|
||||
byAbs[cp.Abs] = cp
|
||||
}
|
||||
uniq = make([]CapturePath, 0, len(byAbs))
|
||||
for _, cp := range byAbs {
|
||||
uniq = append(uniq, cp)
|
||||
}
|
||||
return uniq, skipped
|
||||
}
|
||||
|
||||
// FabBuckets is the class-bucketed capture set for the manual `.fab` export (Task 4). Unlike
|
||||
// ComputeCaptureSet it keeps ALL classes (structural guards run over every class — a traversal path is
|
||||
// never plannable, opt-in or not) and does NOT collapse across containment (mandatory `media/books`
|
||||
// inside excluded `media` both survive, in their own buckets). HasClassification=false ⇒ empty (the
|
||||
// legacy full-root capture, unchanged).
|
||||
type FabBuckets struct {
|
||||
HasClassification bool
|
||||
Mandatory []CapturePath
|
||||
Optional []CapturePath
|
||||
Excluded []CapturePath
|
||||
Skipped []SkippedPath
|
||||
}
|
||||
|
||||
// ComputeFabBuckets resolves an app's classified binds into per-class buckets for the `.fab` export
|
||||
// selection UI + plan. Same resolution + structural guards + equal-Abs collapse as ComputeCaptureSet
|
||||
// (via resolveGuardCollapse), bucketed by class, no cross-bucket containment dedup. Each bucket is
|
||||
// Abs-sorted (deterministic).
|
||||
func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath string) FabBuckets {
|
||||
if !hasClassification {
|
||||
return FabBuckets{HasClassification: false}
|
||||
}
|
||||
fb := FabBuckets{HasClassification: true}
|
||||
uniq, skipped := resolveGuardCollapse(binds, hddPath, func(BindClass) bool { return true })
|
||||
fb.Skipped = skipped
|
||||
for _, cp := range uniq {
|
||||
switch cp.Class {
|
||||
case ClassMandatory:
|
||||
fb.Mandatory = append(fb.Mandatory, cp)
|
||||
case ClassOptional:
|
||||
fb.Optional = append(fb.Optional, cp)
|
||||
default:
|
||||
fb.Excluded = append(fb.Excluded, cp)
|
||||
}
|
||||
}
|
||||
for _, b := range []*[]CapturePath{&fb.Mandatory, &fb.Optional, &fb.Excluded} {
|
||||
bk := *b
|
||||
sort.Slice(bk, func(i, j int) bool { return bk[i].Abs < bk[j].Abs })
|
||||
}
|
||||
sort.Slice(fb.Skipped, func(i, j int) bool {
|
||||
if fb.Skipped[i].Root != fb.Skipped[j].Root {
|
||||
return fb.Skipped[i].Root < fb.Skipped[j].Root
|
||||
}
|
||||
return fb.Skipped[i].RelPath < fb.Skipped[j].RelPath
|
||||
})
|
||||
return fb
|
||||
}
|
||||
|
||||
// tierKeeps applies the §2 tier column: mandatory everywhere, optional only for secondary, excluded
|
||||
// never.
|
||||
func tierKeeps(tier CaptureTier, class BindClass) bool {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package appbackup
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func bucketAbs(b []CapturePath) []string {
|
||||
out := make([]string, 0, len(b))
|
||||
for _, p := range b {
|
||||
out = append(out, p.Abs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// classified app → three buckets, resolved + Abs-sorted.
|
||||
func TestComputeFabBuckets_Classified(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/books"}, Class: ClassMandatory},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/comics"}, Class: ClassOptional},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/movies"}, Class: ClassExcluded},
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/app"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
if !fb.HasClassification {
|
||||
t.Fatal("HasClassification must be true")
|
||||
}
|
||||
if got, want := bucketAbs(fb.Mandatory), []string{hdd("appdata/app"), udat("media/books")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Mandatory = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bucketAbs(fb.Optional), []string{udat("media/comics")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Optional = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bucketAbs(fb.Excluded), []string{udat("media/movies")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Excluded = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// legacy (no block) → empty buckets (the full-root capture stays out of the classified plan).
|
||||
func TestComputeFabBuckets_LegacyEmpty(t *testing.T) {
|
||||
binds := []ClassifiedBind{{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/tv"}, Origin: OriginLegacy}}
|
||||
fb := ComputeFabBuckets(binds, false, drv)
|
||||
if fb.HasClassification || fb.Mandatory != nil || fb.Optional != nil || fb.Excluded != nil {
|
||||
t.Errorf("legacy app must yield empty buckets, got %+v", fb)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario E: structural guards run over ALL classes — a traversal path in an EXCLUDED bind is Skipped,
|
||||
// never plannable (opt-in or not).
|
||||
func TestComputeFabBuckets_GuardsAllClasses(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "../evil"}, Class: ClassExcluded},
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/ok"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
for _, b := range [][]CapturePath{fb.Mandatory, fb.Optional, fb.Excluded} {
|
||||
for _, p := range b {
|
||||
if p.RelPath == "../evil" {
|
||||
t.Fatal("a traversal path must never enter a bucket (guards run over all classes)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(fb.Skipped) != 1 || fb.Skipped[0].RelPath != "../evil" {
|
||||
t.Errorf("traversal excluded path must be Skipped, got %+v", fb.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// no cross-bucket containment dedup: a mandatory CHILD inside an excluded PARENT both survive.
|
||||
func TestComputeFabBuckets_NoCrossBucketContainment(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassExcluded},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/books"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
if got, want := bucketAbs(fb.Mandatory), []string{udat("media/books")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("mandatory child must survive independently: Mandatory = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := bucketAbs(fb.Excluded), []string{udat("media")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("excluded parent must survive: Excluded = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// equal-Abs collapse: two spellings of one path collapse, mandatory wins (into the mandatory bucket).
|
||||
func TestComputeFabBuckets_EqualAbsMandatoryWins(t *testing.T) {
|
||||
binds := []ClassifiedBind{
|
||||
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "userdata/media"}, Class: ClassOptional},
|
||||
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassMandatory},
|
||||
}
|
||||
fb := ComputeFabBuckets(binds, true, drv)
|
||||
if got, want := bucketAbs(fb.Mandatory), []string{udat("media")}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("collapsed path must land in Mandatory, got Mandatory=%v", got)
|
||||
}
|
||||
if len(fb.Optional) != 0 {
|
||||
t.Errorf("optional spelling must collapse away, got %v", bucketAbs(fb.Optional))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// fabWebProvider is a minimal appexport.ExportStackProvider: a config+userdata app (no volumes, no DB)
|
||||
// so a real export runs without docker, and classified so the plan applies.
|
||||
type fabWebProvider struct {
|
||||
stackDir, stacksDir, hddPath string
|
||||
binds []appbackup.ClassifiedBind
|
||||
}
|
||||
|
||||
func (p *fabWebProvider) GetStackDir(string) (string, bool) { return p.stackDir, true }
|
||||
func (p *fabWebProvider) GetStackComposePath(string) (string, bool) {
|
||||
return filepath.Join(p.stackDir, "docker-compose.yml"), true
|
||||
}
|
||||
func (p *fabWebProvider) GetStackHDDMounts(string) []string { return []string{appbackup.UserdataDir(p.hddPath)} }
|
||||
func (p *fabWebProvider) GetStackHDDPath(string) string { return p.hddPath }
|
||||
func (p *fabWebProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) {
|
||||
return p.binds, true
|
||||
}
|
||||
func (p *fabWebProvider) IsStackRunning(string) bool { return false }
|
||||
func (p *fabWebProvider) StopStack(string) error { return nil }
|
||||
func (p *fabWebProvider) StartStack(string) error { return nil }
|
||||
func (p *fabWebProvider) GetStackDisplayName(n string) string { return n }
|
||||
func (p *fabWebProvider) GetStackNeedsHDD(string) bool { return true }
|
||||
func (p *fabWebProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *fabWebProvider) IsStackDeployed(string) bool { return true }
|
||||
func (p *fabWebProvider) GetDecryptedEnv(string) map[string]string { return nil }
|
||||
func (p *fabWebProvider) GetStacksBaseDir() string { return p.stacksDir }
|
||||
func (p *fabWebProvider) SaveEncryptedAppConfig(string, map[string]string) error { return nil }
|
||||
func (p *fabWebProvider) RefreshStacks() error { return nil }
|
||||
func (p *fabWebProvider) RemoveStackVolumes(string) error { return nil }
|
||||
|
||||
// waitExportDone polls the exporter until the active job finishes.
|
||||
func waitExportDone(t *testing.T, e *appexport.Exporter) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if j := e.GetActiveJob(); j != nil {
|
||||
if done, _ := j.Snapshot()["done"].(bool); done {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("export did not finish")
|
||||
}
|
||||
|
||||
// findFab returns the newest .fab under dir.
|
||||
func findFab(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
var found string
|
||||
filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err == nil && strings.HasSuffix(p, ".fab") {
|
||||
found = p
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if found == "" {
|
||||
t.Fatalf("no .fab under %s", dir)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// userdataTarEntries extracts data/hdd/userdata.tar from an UNENCRYPTED .fab and lists its entries.
|
||||
func userdataTarEntries(t *testing.T, fabPath string) []string {
|
||||
t.Helper()
|
||||
f, err := os.Open(fabPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
var inner []byte
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.HasSuffix(filepath.ToSlash(h.Name), "hdd/userdata.tar") {
|
||||
inner, _ = io.ReadAll(tr)
|
||||
}
|
||||
}
|
||||
if inner == nil {
|
||||
return nil // no userdata tar in the bundle
|
||||
}
|
||||
itr := tar.NewReader(strings.NewReader(string(inner)))
|
||||
var names []string
|
||||
for {
|
||||
h, err := itr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names = append(names, filepath.ToSlash(h.Name))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// §7-F / red-proof F: BOTH start pipelines must carry the class selection. Each produces a bundle;
|
||||
// with OptInExcluded, the excluded content must be present in the userdata tar. Red-proof: drop the
|
||||
// selection fields from one start handler → that pipeline's assertion fails (two-call-site).
|
||||
func TestFab_SelectionsRideBothStartPipelines(t *testing.T) {
|
||||
build := func(t *testing.T) (*Server, *fabWebProvider, *appexport.Exporter, string) {
|
||||
s := testServer(t)
|
||||
s.cfg.Paths.DataDir = t.TempDir()
|
||||
drive := t.TempDir()
|
||||
stackDir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0644)
|
||||
fabWrite(t, drive, "userdata/media/books/a.epub", "BOOK")
|
||||
fabWrite(t, drive, "userdata/media/movies/big.mkv", "MOVIE") // excluded, opted-in below
|
||||
prov := &fabWebProvider{stackDir: stackDir, stacksDir: t.TempDir(), hddPath: drive,
|
||||
binds: []appbackup.ClassifiedBind{
|
||||
{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: "media/books"}, Class: appbackup.ClassMandatory},
|
||||
{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: "media/movies"}, Class: appbackup.ClassExcluded},
|
||||
}}
|
||||
e := appexport.NewExporter(prov, s.logger, "test")
|
||||
s.appExporter = e
|
||||
if err := s.settings.AddStoragePath(settings.StoragePath{Path: drive, Label: "d", Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, prov, e, drive
|
||||
}
|
||||
|
||||
post := func(s *Server, path, body string, h func(http.ResponseWriter, *http.Request)) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
h(rr, req)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||
if resp["ok"] != true {
|
||||
// jsonResponse uses {"ok":true}; some paths wrap in {"data":..}. Accept 200.
|
||||
if rr.Code != http.StatusOK {
|
||||
panic("start failed: " + rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pipeline 1: /api/export/start (dest = the registered drive).
|
||||
t.Run("api/export/start", func(t *testing.T) {
|
||||
s, _, e, drive := build(t)
|
||||
body, _ := json.Marshal(map[string]interface{}{"stack_name": "calibre-web", "dest_drive": drive, "opt_in_excluded": []string{"userdata/media/movies"}})
|
||||
post(s, "/api/export/start", string(body), s.apiExportStart)
|
||||
waitExportDone(t, e)
|
||||
got := userdataTarEntries(t, findFab(t, filepath.Join(drive, "exports")))
|
||||
if !hasSuffixIn(got, "media/movies/big.mkv") {
|
||||
t.Errorf("start pipeline dropped opt_in_excluded — movies absent: %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
// Pipeline 2: /api/export/download/start (dest = the staging download dir).
|
||||
t.Run("api/export/download/start", func(t *testing.T) {
|
||||
s, _, e, _ := build(t)
|
||||
body := `{"stack_name":"calibre-web","opt_in_excluded":["userdata/media/movies"]}`
|
||||
post(s, "/api/export/download/start", body, s.apiExportDownloadStart)
|
||||
waitExportDone(t, e)
|
||||
got := userdataTarEntries(t, findFab(t, s.fabDownloadDir()))
|
||||
if !hasSuffixIn(got, "media/movies/big.mkv") {
|
||||
t.Errorf("download pipeline dropped opt_in_excluded — movies absent: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func hasSuffixIn(ss []string, suffix string) bool {
|
||||
for _, s := range ss {
|
||||
if strings.HasSuffix(filepath.ToSlash(s), suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -168,10 +168,12 @@ func (s *Server) apiExportStart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
StackName string `json:"stack_name"`
|
||||
DestDrive string `json:"dest_drive"`
|
||||
Password string `json:"password"`
|
||||
StopApp bool `json:"stop_app"`
|
||||
StackName string `json:"stack_name"`
|
||||
DestDrive string `json:"dest_drive"`
|
||||
Password string `json:"password"`
|
||||
StopApp bool `json:"stop_app"`
|
||||
DeselectOptional []string `json:"deselect_optional"` // Task 4: `.fab` class selection
|
||||
OptInExcluded []string `json:"opt_in_excluded"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.logger.Printf("[DEBUG] [web] apiExportStart: invalid body: %v", err)
|
||||
@@ -199,10 +201,12 @@ func (s *Server) apiExportStart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
err := s.appExporter.StartExport(appexport.ExportRequest{
|
||||
StackName: req.StackName,
|
||||
DestDrive: req.DestDrive,
|
||||
Password: req.Password,
|
||||
StopApp: req.StopApp,
|
||||
StackName: req.StackName,
|
||||
DestDrive: req.DestDrive,
|
||||
Password: req.Password,
|
||||
StopApp: req.StopApp,
|
||||
DeselectOptional: req.DeselectOptional,
|
||||
OptInExcluded: req.OptInExcluded,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Export start failed for %s: %v", req.StackName, err)
|
||||
|
||||
@@ -78,9 +78,11 @@ func (s *Server) apiExportDownloadStart(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
StackName string `json:"stack_name"`
|
||||
Password string `json:"password"`
|
||||
StopApp bool `json:"stop_app"`
|
||||
StackName string `json:"stack_name"`
|
||||
Password string `json:"password"`
|
||||
StopApp bool `json:"stop_app"`
|
||||
DeselectOptional []string `json:"deselect_optional"` // Task 4: `.fab` class selection (two-call-site)
|
||||
OptInExcluded []string `json:"opt_in_excluded"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "Invalid request body", http.StatusBadRequest)
|
||||
@@ -98,10 +100,12 @@ func (s *Server) apiExportDownloadStart(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
// A concurrent export/import gets the exporter's own busy answer (single-flight).
|
||||
if err := s.appExporter.StartExport(appexport.ExportRequest{
|
||||
StackName: req.StackName,
|
||||
DestDrive: s.fabDownloadRoot(),
|
||||
Password: req.Password,
|
||||
StopApp: req.StopApp,
|
||||
StackName: req.StackName,
|
||||
DestDrive: s.fabDownloadRoot(),
|
||||
Password: req.Password,
|
||||
StopApp: req.StopApp,
|
||||
DeselectOptional: req.DeselectOptional,
|
||||
OptInExcluded: req.OptInExcluded,
|
||||
}); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] download-export start failed for %s: %v", req.StackName, err)
|
||||
jsonError(w, err.Error(), http.StatusConflict)
|
||||
|
||||
@@ -37,6 +37,23 @@
|
||||
<div id="estWarning" style="display:none;color:var(--crit);margin-top:.5rem;font-weight:600"></div>
|
||||
</div>
|
||||
|
||||
<div id="classSelect" style="display:none;margin-bottom:1.5rem">
|
||||
<div id="mandBox" style="display:none;margin-bottom:1rem">
|
||||
<div style="font-weight:600;margin-bottom:.25rem">Mindig része a mentésnek:</div>
|
||||
<div id="mandList" style="color:var(--text-3);font-size:.9rem"></div>
|
||||
</div>
|
||||
<div id="optBox" style="display:none;margin-bottom:1rem">
|
||||
<div style="font-weight:600;margin-bottom:.25rem">Választható tartalom:</div>
|
||||
<div id="optList"></div>
|
||||
</div>
|
||||
<div id="exclBox" style="display:none">
|
||||
<div id="exclSummary" style="margin-bottom:.25rem"></div>
|
||||
<div style="color:var(--text-3);font-size:.85rem;margin-bottom:.5rem">A kihagyott mappák tartalma a Fájlkezelőben bármikor elérhető, és külön is lementhető.</div>
|
||||
<button type="button" class="btn btn-xs btn-outline" onclick="toggleExcl()" id="exclToggle">Kihagyott tartalom megjelenítése</button>
|
||||
<div id="exclList" style="display:none;margin-top:.5rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Jelszó (opcionális)</h3>
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
|
||||
<input type="password" id="exportPassword" placeholder="Titkosítási jelszó" style="flex:1;padding:.5rem">
|
||||
@@ -122,11 +139,84 @@ async function loadEstimate() {
|
||||
btn.disabled = false;
|
||||
}
|
||||
box.style.display = 'block';
|
||||
renderClassSelect(est);
|
||||
} catch(e) {
|
||||
console.error('Estimate error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Task 4: the class-scoped selection UI (classified apps only; legacy apps see the plain estimate).
|
||||
var lastEst = null;
|
||||
function humanBytes(b) {
|
||||
if (b >= 1073741824) return (b/1073741824).toFixed(1) + ' GB';
|
||||
if (b >= 1048576) return (b/1048576).toFixed(1) + ' MB';
|
||||
if (b >= 1024) return (b/1024).toFixed(1) + ' KB';
|
||||
return b + ' B';
|
||||
}
|
||||
function renderClassSelect(est) {
|
||||
lastEst = est;
|
||||
var wrap = document.getElementById('classSelect');
|
||||
if (!est.has_classification) { wrap.style.display = 'none'; return; }
|
||||
wrap.style.display = 'block';
|
||||
|
||||
var mand = est.mandatory_items || [], opt = est.optional_items || [], excl = est.excluded_items || [];
|
||||
// Mandatory (locked).
|
||||
var mb = document.getElementById('mandBox');
|
||||
if (mand.length) {
|
||||
mb.style.display = 'block';
|
||||
document.getElementById('mandList').innerHTML = mand.map(function(i){
|
||||
return '<div>' + esc(i.rel_path) + ' <span style="opacity:.7">(' + i.human + ')</span></div>';
|
||||
}).join('');
|
||||
} else { mb.style.display = 'none'; }
|
||||
// Optional (pre-selected checkboxes).
|
||||
var ob = document.getElementById('optBox');
|
||||
if (opt.length) {
|
||||
ob.style.display = 'block';
|
||||
document.getElementById('optList').innerHTML = opt.map(function(i){
|
||||
return '<label style="display:flex;gap:.5rem;align-items:center;cursor:pointer;padding:.15rem 0">' +
|
||||
'<input type="checkbox" class="fab-opt" checked data-key="' + esc(i.key) + '" data-bytes="' + i.bytes + '" onchange="recomputeTotal()">' +
|
||||
'<span>' + esc(i.rel_path) + ' <span style="opacity:.7">(' + i.human + ')</span></span></label>';
|
||||
}).join('');
|
||||
} else { ob.style.display = 'none'; }
|
||||
// Excluded (opt-in, collapsed, behind the two-number warning).
|
||||
var eb = document.getElementById('exclBox');
|
||||
if (excl.length) {
|
||||
eb.style.display = 'block';
|
||||
document.getElementById('exclList').innerHTML = excl.map(function(i){
|
||||
return '<label style="display:flex;gap:.5rem;align-items:center;cursor:pointer;padding:.15rem 0">' +
|
||||
'<input type="checkbox" class="fab-excl" data-key="' + esc(i.key) + '" data-bytes="' + i.bytes + '" onchange="recomputeTotal()">' +
|
||||
'<span>' + esc(i.rel_path) + ' <span style="opacity:.7">(' + i.human + ')</span></span></label>';
|
||||
}).join('');
|
||||
} else { eb.style.display = 'none'; }
|
||||
recomputeTotal();
|
||||
}
|
||||
function recomputeTotal() {
|
||||
if (!lastEst) return;
|
||||
var base = lastEst.base_bytes || 0;
|
||||
var optSum = 0, exclSum = 0, exclAll = 0;
|
||||
document.querySelectorAll('.fab-opt').forEach(function(cb){ if (cb.checked) optSum += parseInt(cb.dataset.bytes,10)||0; });
|
||||
document.querySelectorAll('.fab-excl').forEach(function(cb){ var b = parseInt(cb.dataset.bytes,10)||0; exclAll += b; if (cb.checked) exclSum += b; });
|
||||
var alap = base + optSum;
|
||||
var full = alap + exclAll;
|
||||
var s = document.getElementById('exclSummary');
|
||||
if ((lastEst.excluded_items||[]).length) {
|
||||
s.innerHTML = 'Alap mentés: ~' + humanBytes(alap) + '. A kihagyott, nagy méretű tartalommal együtt: ~' + humanBytes(full) + '.';
|
||||
}
|
||||
}
|
||||
function toggleExcl() {
|
||||
var l = document.getElementById('exclList');
|
||||
var shown = l.style.display !== 'none';
|
||||
l.style.display = shown ? 'none' : 'block';
|
||||
document.getElementById('exclToggle').textContent = shown ? 'Kihagyott tartalom megjelenítése' : 'Kihagyott tartalom elrejtése';
|
||||
}
|
||||
function gatherSelections() {
|
||||
var deselect = [], optIn = [];
|
||||
document.querySelectorAll('.fab-opt').forEach(function(cb){ if (!cb.checked) deselect.push(cb.dataset.key); });
|
||||
document.querySelectorAll('.fab-excl').forEach(function(cb){ if (cb.checked) optIn.push(cb.dataset.key); });
|
||||
return { deselect_optional: deselect, opt_in_excluded: optIn };
|
||||
}
|
||||
function esc(s) { return String(s).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); }
|
||||
|
||||
async function startExport() {
|
||||
var drive = document.getElementById('destDrive').value;
|
||||
var password = document.getElementById('exportPassword').value;
|
||||
@@ -137,6 +227,7 @@ async function startExport() {
|
||||
document.getElementById('doneCard').style.display = 'none';
|
||||
|
||||
try {
|
||||
var sel = gatherSelections();
|
||||
var resp = await fetch('/api/export/start', {
|
||||
method: 'POST',
|
||||
headers: csrfH(),
|
||||
@@ -144,7 +235,9 @@ async function startExport() {
|
||||
stack_name: stackName,
|
||||
dest_drive: drive,
|
||||
password: password,
|
||||
stop_app: stopApp
|
||||
stop_app: stopApp,
|
||||
deselect_optional: sel.deselect_optional,
|
||||
opt_in_excluded: sel.opt_in_excluded
|
||||
})
|
||||
});
|
||||
var data = await resp.json();
|
||||
|
||||
Reference in New Issue
Block a user