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.
100 lines
3.9 KiB
Go
100 lines
3.9 KiB
Go
package stacks
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
|
)
|
|
|
|
// `data_paths:` — the customer-facing folder ANNOTATION (R-75).
|
|
//
|
|
// It annotates paths that ALREADY EXIST as compose binds; it can never declare a new one. That is
|
|
// what keeps it cheap: no new filesystem-write primitive is introduced from catalog data, because
|
|
// every path it names is a path the deploy belt already creates.
|
|
|
|
// DataPathRole is the presentation role of an annotated folder.
|
|
type DataPathRole string
|
|
|
|
const (
|
|
RoleImport DataPathRole = "import" // a drop-zone: the app consumes and usually DELETES from it
|
|
RoleLibrary DataPathRole = "library" // where the customer's files live
|
|
RoleExport DataPathRole = "export" // the app writes results here for the customer to collect
|
|
)
|
|
|
|
// DataPath is one entry of the `data_paths:` block.
|
|
type DataPath struct {
|
|
Path string `yaml:"path" json:"path"` // relative to Root, must match a compose bind
|
|
Root appbackup.BindRoot `yaml:"root" json:"root"` // import | userdata | hdd
|
|
Role DataPathRole `yaml:"role" json:"role"` // import | library | export
|
|
Label string `yaml:"label" json:"label"` // Hungarian, customer-facing
|
|
}
|
|
|
|
// validRole reports whether r is a role this controller knows how to render.
|
|
func validRole(r DataPathRole) bool {
|
|
switch r {
|
|
case RoleImport, RoleLibrary, RoleExport:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ValidateDataPaths applies the Fork-3 ruling, and the ASYMMETRY is deliberate — a decision, not an
|
|
// inconsistency:
|
|
//
|
|
// - A MALFORMED PATH is a whole-block reject (returns an error; the caller drops the entire block
|
|
// and the app renders no folder card). Paths govern DATA HANDLING: a path that is absolute,
|
|
// escapes the root, or matches no compose bind would point the customer at a directory that is
|
|
// not the app's, so nothing from that block can be trusted. This follows the `backup:` precedent
|
|
// and reuses its refusal set (appbackup.ValidateRelPath) rather than defining a second one.
|
|
//
|
|
// - An UNKNOWN ROLE fails OPEN (that one entry is dropped with a WARN; everything else renders).
|
|
// Roles govern PRESENTATION only, and this follows the Lifecycle precedent (metadata.go): a typo
|
|
// in a catalog push must never brick a template. The cost of an unknown role is one missing UI
|
|
// affordance, never a mishandled file.
|
|
//
|
|
// Returns the surviving entries. An error means the caller must discard the WHOLE block.
|
|
func ValidateDataPaths(entries []DataPath, binds []appbackup.ComposeBind, appName string, logger *log.Logger) ([]DataPath, error) {
|
|
if len(entries) == 0 {
|
|
return nil, nil
|
|
}
|
|
present := make(map[appbackup.BindRoot]map[string]bool)
|
|
for _, b := range binds {
|
|
if present[b.Root] == nil {
|
|
present[b.Root] = make(map[string]bool)
|
|
}
|
|
present[b.Root][b.RelPath] = true
|
|
}
|
|
|
|
seen := make(map[string]bool)
|
|
out := make([]DataPath, 0, len(entries))
|
|
for _, e := range entries {
|
|
// --- path rules: whole-block reject ---
|
|
if !appbackup.ValidRoot(e.Root) {
|
|
return nil, fmt.Errorf("data_paths[%q]: unknown root %q (want import|userdata|hdd)", e.Path, e.Root)
|
|
}
|
|
if err := appbackup.ValidateRelPath(e.Root, e.Path); err != nil {
|
|
return nil, fmt.Errorf("data_paths: %w", err)
|
|
}
|
|
key := string(e.Root) + "\x00" + e.Path
|
|
if seen[key] {
|
|
return nil, fmt.Errorf("data_paths[%s/%s]: duplicate entry", e.Root, e.Path)
|
|
}
|
|
seen[key] = true
|
|
if !present[e.Root][e.Path] {
|
|
return nil, fmt.Errorf("data_paths[%s/%s]: matches no compose bind — data_paths ANNOTATES existing binds, it cannot declare new ones", e.Root, e.Path)
|
|
}
|
|
// --- role: fail OPEN ---
|
|
if !validRole(e.Role) {
|
|
if logger != nil {
|
|
logger.Printf("[WARN] [stacks] %s: unknown data_paths role %q for %s/%s — entry not surfaced (known: %s, %s, %s)",
|
|
appName, e.Role, e.Root, e.Path, RoleImport, RoleLibrary, RoleExport)
|
|
}
|
|
continue
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, nil
|
|
}
|