73efb091d9
gates / gates (push) Successful in 9s
appbackup's path helpers take a NAMESPACE ROOT. Five call sites passed a bare DRIVE path.
On an enrolled drive the two coincide, so nothing showed; on the system-data fallback they
differ by exactly the felhom-data segment, and the app then bound a directory the off-site
capture set never looked at -- while the run reported ok. Measured live on demo-hp: the app
wrote to /mnt/sys_drive/userdata/media/books, the capture set looked for
/mnt/sys_drive/felhom-data/userdata/media/books.
THE RULE NOW HAS ONE EXPRESSION. appbackup.NamespaceRootFor / IsEnrolledDrive encode the
drive-kind comparison; backup.Manager.namespaceRoot and stacks.Manager.inGuest delegate to
it. There were already TWO copies and they differed -- the backup package's compared without
filepath.Clean, the stacks package's with it, so a trailing slash from config would have
flipped the mode in one and not the other.
Sites routed through it:
- stacks/deploy.go withPathVars -> ${USERDATA_PATH} (the live defect)
- appexport/fabplan.go + export.go (via a new provider method)
- web/handlers.go FileBrowser mounts (latent: the system drive is
deliberately never a registered StoragePath, so this is the identity today)
ComputeFabBuckets now receives the namespace root, which is what ComputeCaptureSet has always
received -- so the export's classified paths and the backup's capture set describe the same
directories by construction instead of by coincidence.
Tests are table-driven over BOTH drive kinds, because this survived by being invisible on the
kind that already worked. Red-proofs observed: restoring the bare-path call fails the
system-drive row with the two paths differing by /felhom-data; inverting the drive-kind
comparison fails every enrolled row.
312 lines
10 KiB
Go
312 lines
10 KiB
Go
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
|
|
}
|
|
// R-203: the shared resolver's root parameter is a NAMESPACE ROOT — that is what the off-site
|
|
// side has always passed (ComputeCaptureSet ← offbox_capture.go). This site passed the bare drive
|
|
// path, so on the system-data fallback the export's classified paths and the backup's capture set
|
|
// described DIFFERENT directories for the same declared bind. They now agree by construction.
|
|
nsRoot := filepath.Clean(e.provider.GetStackNamespaceRoot(req.StackName))
|
|
fb := appbackup.ComputeFabBuckets(binds, has, nsRoot, e.provider.GetImportRoot())
|
|
|
|
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{}}
|
|
// R-203: UserdataDir takes a NAMESPACE ROOT, not the drive path. Identical on an enrolled drive;
|
|
// one segment short on the system-data fallback, which is where the export plan then skipped (or
|
|
// failed to skip) the wrong directory.
|
|
ud := appbackup.UserdataDir(nsRoot)
|
|
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
|
|
}
|
|
nsRoot := filepath.Clean(e.provider.GetStackNamespaceRoot(stackName)) // R-203, as above
|
|
fb := appbackup.ComputeFabBuckets(binds, has, nsRoot, e.provider.GetImportRoot())
|
|
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
|
|
})
|
|
}
|