Files
felhom-controller/controller/internal/stacks/datapaths.go
T
admin 2958946517 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.
2026-07-26 08:12:57 +02:00

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
}