2958946517
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).
Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.
Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.
Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.
One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.
Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.
Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.
Tests 915 -> 949, all green. MinAgent unchanged.
121 lines
4.2 KiB
Go
121 lines
4.2 KiB
Go
package stacks
|
|
|
|
import (
|
|
"bufio"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
)
|
|
|
|
// composeVarRoots maps the two deploy-time path variables to their classification root. A bind's
|
|
// host side must reference one of these LITERALLY (the classifier works in ${VAR}-relative space —
|
|
// it does NOT resolve envs, unlike ParseComposeHDDMounts). ${USERDATA_PATH} is <HDD_PATH>/userdata
|
|
// at deploy, but in the compose file the two are written distinctly, so they are distinct roots.
|
|
var composeVarRoots = []struct {
|
|
varRef string
|
|
root appbackup.BindRoot
|
|
}{
|
|
{"${USERDATA_PATH}", appbackup.RootUserdata},
|
|
{"${HDD_PATH}", appbackup.RootHDD},
|
|
// ${IMPORT_PATH} (R-75) is the canonical drop-zone root on the SYSTEM drive. It must be listed
|
|
// BEFORE any prefix of it could match — it shares no prefix with the other two, so order is not
|
|
// load-bearing here, but the classifier works in ${VAR}-relative space and never resolves envs,
|
|
// so this entry is what makes an ingest bind classifiable at all.
|
|
{"${IMPORT_PATH}", appbackup.RootImport},
|
|
}
|
|
|
|
// ParseComposeClassifiableBinds extracts the ${HDD_PATH}/${USERDATA_PATH}-relative host binds from a
|
|
// docker-compose.yml, for backup classification (Part 2 of the classification arc). It copies the
|
|
// ParseComposeUserdataMounts scanner shape (service-level `volumes:` section, `- ` short-syntax
|
|
// lines, quote-trim, `SplitN(":",3)`) but stays in RELATIVE ${VAR} space and preserves the `:ro`
|
|
// flag — both of which the classifier needs and which ParseComposeHDDMounts discards.
|
|
//
|
|
// RelPath is the path.Clean'd remainder after the variable (leading "/" stripped; "" for a bare-root
|
|
// bind). ReadOnly is true iff the mode field (parts[2]) contains a `ro` token. Deduped on
|
|
// (Root, RelPath) — the FIRST occurrence's ReadOnly wins (the catalog never mixes modes for one
|
|
// path; noted so a future mixed case is a conscious change, not a silent one).
|
|
//
|
|
// Long-syntax volumes (`type: bind`) are NOT supported — parity with every existing compose parser;
|
|
// the catalog uses short syntax only. Pure given the file bytes (no env resolution, no FS beyond the
|
|
// read).
|
|
func ParseComposeClassifiableBinds(composePath string) []appbackup.ComposeBind {
|
|
data, err := os.ReadFile(composePath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var binds []appbackup.ComposeBind
|
|
seen := make(map[string]bool) // "<root>\x00<relpath>"
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
|
inVolumes := false
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if strings.HasPrefix(line, "volumes:") {
|
|
inVolumes = true
|
|
continue
|
|
}
|
|
if inVolumes && !strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "#") && line != "" {
|
|
inVolumes = false
|
|
}
|
|
if !inVolumes || !strings.HasPrefix(line, "- ") {
|
|
continue
|
|
}
|
|
mountStr := strings.Trim(strings.TrimPrefix(line, "- "), "\"'")
|
|
parts := strings.SplitN(mountStr, ":", 3)
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
root, relPath, ok := classifyRoot(parts[0])
|
|
if !ok {
|
|
continue
|
|
}
|
|
readOnly := len(parts) == 3 && modeIsReadOnly(parts[2])
|
|
key := string(root) + "\x00" + relPath
|
|
if seen[key] {
|
|
continue // first occurrence's ReadOnly wins
|
|
}
|
|
seen[key] = true
|
|
binds = append(binds, appbackup.ComposeBind{Root: root, RelPath: relPath, ReadOnly: readOnly})
|
|
}
|
|
return binds
|
|
}
|
|
|
|
// classifyRoot resolves a compose host-side token to its (root, relpath) if it references one of the
|
|
// classification variables exactly or as a "/"-separated prefix. relPath is path.Clean'd, "" for a
|
|
// bare root.
|
|
func classifyRoot(hostPath string) (appbackup.BindRoot, string, bool) {
|
|
for _, v := range composeVarRoots {
|
|
var rem string
|
|
switch {
|
|
case hostPath == v.varRef:
|
|
rem = ""
|
|
case strings.HasPrefix(hostPath, v.varRef+"/"):
|
|
rem = strings.TrimPrefix(hostPath, v.varRef+"/")
|
|
default:
|
|
continue
|
|
}
|
|
if rem == "" {
|
|
return v.root, "", true
|
|
}
|
|
rel := path.Clean(rem)
|
|
if rel == "." {
|
|
rel = ""
|
|
}
|
|
return v.root, rel, true
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
// modeIsReadOnly reports whether a docker volume mode field (e.g. "ro", "rw", "ro,z", "z") carries a
|
|
// `ro` token.
|
|
func modeIsReadOnly(mode string) bool {
|
|
for _, tok := range strings.Split(mode, ",") {
|
|
if strings.TrimSpace(tok) == "ro" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|