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 }) }