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