v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces
${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.
This commit is contained in:
@@ -179,9 +179,63 @@ func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureUserdataMounts is the deploy belt: pre-create every ${USERDATA_PATH}/... bind source the
|
||||
// stack declares with the userdata convention, so Docker never auto-creates one as guest-root.
|
||||
// GetImportRoot returns the CANONICAL drop-zone root (R-75): <system namespace root>/userdata/import.
|
||||
//
|
||||
// It is resolved from the SYSTEM drive, never from the app's HDD_PATH, so every app's drop-zone lands
|
||||
// in one place regardless of which drive the app was deployed to. The system drive holds a felhom-data
|
||||
// SUBDIR (it is not itself the namespace root — that is the inGuestDrive=false case), which is why
|
||||
// NamespaceRoot is applied rather than using the configured path directly.
|
||||
//
|
||||
// Returns "" when the system data path is unconfigured. Callers must NOT substitute a per-drive
|
||||
// fallback: that would put a folder that looks like a drop-zone on every drive while only one works.
|
||||
// withPathVars leaves IMPORT_PATH unset instead, so compose fails loudly on ${IMPORT_PATH}.
|
||||
//
|
||||
// NOTE: the system drive is deliberately NOT a registered StoragePath (verified on both demo boxes,
|
||||
// 2026-07-26), so this root is invisible to the storage UI, to buildFileBrowserPaths' per-path loop
|
||||
// and to sharingResolvePath's owning-root check. Everything that must reach it does so explicitly —
|
||||
// see EnsureImportRoot, the FileBrowser import bind, and the System SMB share.
|
||||
func (m *Manager) GetImportRoot() string {
|
||||
sys := m.cfg.Paths.SystemDataPath
|
||||
if sys == "" {
|
||||
m.logger.Printf("[ERROR] [stacks] IMPORT_PATH unresolvable: paths.system_data_path is empty — a drop-zone bind will fail to resolve rather than silently land on a data drive")
|
||||
return ""
|
||||
}
|
||||
return appbackup.ImportDir(appbackup.NamespaceRoot(sys, false))
|
||||
}
|
||||
|
||||
// ensureUserdataMounts is the deploy belt: pre-create every ${USERDATA_PATH}/... and ${IMPORT_PATH}/...
|
||||
// bind source the stack declares with the userdata convention, so Docker never auto-creates one as
|
||||
// guest-root.
|
||||
//
|
||||
// The two roots are gated DIFFERENTLY and that is load-bearing. ${USERDATA_PATH} is on the app's own
|
||||
// data drive and is subject to the drive-absent gate; ${IMPORT_PATH} (R-75) is on the SYSTEM drive,
|
||||
// which is always present, so gating it on a detached data drive would refuse to create a directory
|
||||
// that has nothing to do with that drive.
|
||||
func (m *Manager) ensureUserdataMounts(stackDir string, env []string) {
|
||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
||||
binds := ParseComposeClassifiableBinds(composePath)
|
||||
|
||||
// --- import binds: system drive, never drive-gated ---
|
||||
if importPath := envLookup(env, "IMPORT_PATH"); importPath != "" {
|
||||
for _, b := range binds {
|
||||
if b.Root != appbackup.RootImport {
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(importPath, filepath.FromSlash(b.RelPath))
|
||||
if err := appbackup.EnsureUserdataDir(src); err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] import belt: ensure %s: %v", src, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, b := range binds {
|
||||
if b.Root == appbackup.RootImport {
|
||||
m.logger.Printf("[ERROR] [stacks] import belt: stack declares a ${IMPORT_PATH} bind but IMPORT_PATH is unset — compose will fail rather than bind a wrong-drive path")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- userdata binds: the app's own drive, drive-absent gated ---
|
||||
userdataPath := envLookup(env, "USERDATA_PATH")
|
||||
if userdataPath == "" {
|
||||
return
|
||||
@@ -195,8 +249,11 @@ func (m *Manager) ensureUserdataMounts(stackDir string, env []string) {
|
||||
m.logger.Printf("[INFO] [stacks] userdata belt: drive %s not mounted — skipping ensure (held by drive gate)", hdd)
|
||||
return
|
||||
}
|
||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
||||
for _, src := range ParseComposeUserdataMounts(composePath, userdataPath) {
|
||||
for _, b := range binds {
|
||||
if b.Root != appbackup.RootUserdata {
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(userdataPath, filepath.FromSlash(b.RelPath))
|
||||
if err := appbackup.EnsureUserdataDir(src); err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] userdata belt: ensure %s: %v", src, err)
|
||||
}
|
||||
@@ -1071,7 +1128,8 @@ func (m *Manager) stackEnv(stackDir string) []string {
|
||||
// Inject USERDATA_PATH = <namespace root>/userdata alongside HDD_PATH (v0.66.0). HDD_PATH IS
|
||||
// the namespace root (the chosen StoragePath: a Model-A user drive's mount, or the SSD's
|
||||
// felhom-data dir), so the catalog's ${USERDATA_PATH}/... mounts resolve under userdata/.
|
||||
env = withUserdataPath(env, appCfg.Env["HDD_PATH"])
|
||||
// IMPORT_PATH (R-75) rides along but is derived from the SYSTEM drive, never from HDD_PATH.
|
||||
env = withPathVars(env, appCfg.Env["HDD_PATH"], m.GetImportRoot())
|
||||
}
|
||||
|
||||
// App-email relay env (appended LAST so it wins over any app.yaml default). Returns nil unless
|
||||
|
||||
Reference in New Issue
Block a user