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:
@@ -47,12 +47,12 @@ backup:
|
||||
wantPhotos := path.Join(hddPath, "userdata", "media/photos")
|
||||
|
||||
// Secondary: mandatory + optional, sorted by Abs.
|
||||
sec := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierSecondary, hddPath)
|
||||
sec := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierSecondary, hddPath, "")
|
||||
if got, want := absSet(sec), []string{wantWiretest, wantPhotos}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("secondary Paths = %v, want %v", got, want)
|
||||
}
|
||||
// Offsite: mandatory only — the explicit-optional :ro library must NOT appear.
|
||||
off := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierOffsite, hddPath)
|
||||
off := appbackup.ComputeCaptureSet(cbs, has, appbackup.TierOffsite, hddPath, "")
|
||||
if got, want := absSet(off), []string{wantWiretest}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("offsite Paths = %v, want %v (optional :ro must not ship offsite)", got, want)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ var composeVarRoots = []struct {
|
||||
}{
|
||||
{"${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
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
)
|
||||
|
||||
func dpBinds() []appbackup.ComposeBind {
|
||||
return []appbackup.ComposeBind{
|
||||
{Root: appbackup.RootImport, RelPath: "paperless"},
|
||||
{Root: appbackup.RootUserdata, RelPath: "media/books"},
|
||||
{Root: appbackup.RootHDD, RelPath: "appdata/paperless/media"},
|
||||
}
|
||||
}
|
||||
|
||||
func quietLogger() *log.Logger { return log.New(io.Discard, "", 0) }
|
||||
|
||||
// Fork-3 ruling, half 1: a MALFORMED PATH is a WHOLE-BLOCK reject. Paths govern data handling.
|
||||
func TestDataPaths_MalformedPathWholeBlockRejects(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
entry DataPath
|
||||
}{
|
||||
{"absolute", DataPath{Path: "/etc/passwd", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"traversal", DataPath{Path: "../../etc", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"backslash", DataPath{Path: `a\b`, Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"non-clean", DataPath{Path: "a//b", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"empty", DataPath{Path: "", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"matches no compose bind", DataPath{Path: "nincs-ilyen", Root: appbackup.RootImport, Role: RoleImport, Label: "x"}},
|
||||
{"unknown root", DataPath{Path: "paperless", Root: appbackup.BindRoot("bogus"), Role: RoleImport, Label: "x"}},
|
||||
} {
|
||||
// A VALID entry sits alongside it — the reject must take the whole block, not just the bad one.
|
||||
entries := []DataPath{
|
||||
{Path: "media/books", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "ok"},
|
||||
tc.entry,
|
||||
}
|
||||
kept, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
|
||||
if err == nil {
|
||||
t.Errorf("%s: must be rejected, got kept=%v", tc.name, kept)
|
||||
}
|
||||
if kept != nil {
|
||||
t.Errorf("%s: a whole-block reject must keep NOTHING, got %v", tc.name, kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fork-3 ruling, half 2: an UNKNOWN ROLE fails OPEN. Roles govern presentation only, so a typo in a
|
||||
// catalog push must never brick the template (the Lifecycle precedent).
|
||||
func TestDataPaths_UnknownRoleFailsOpen(t *testing.T) {
|
||||
entries := []DataPath{
|
||||
{Path: "paperless", Root: appbackup.RootImport, Role: DataPathRole("beolvasas"), Label: "typo role"},
|
||||
{Path: "media/books", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "E-könyvtár"},
|
||||
}
|
||||
kept, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("an unknown ROLE must not reject the block: %v", err)
|
||||
}
|
||||
if len(kept) != 1 || kept[0].Path != "media/books" {
|
||||
t.Errorf("expected only the valid entry to survive, got %v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
// The happy path across all three roots and roles.
|
||||
func TestDataPaths_AllRootsAndRoles(t *testing.T) {
|
||||
entries := []DataPath{
|
||||
{Path: "paperless", Root: appbackup.RootImport, Role: RoleImport, Label: "Beolvasandó dokumentumok"},
|
||||
{Path: "media/books", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "E-könyvtár"},
|
||||
{Path: "appdata/paperless/media", Root: appbackup.RootHDD, Role: RoleExport, Label: "Export"},
|
||||
}
|
||||
kept, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("valid block rejected: %v", err)
|
||||
}
|
||||
if len(kept) != 3 {
|
||||
t.Errorf("expected all 3 entries kept, got %d: %v", len(kept), kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataPaths_DuplicateRejects(t *testing.T) {
|
||||
entries := []DataPath{
|
||||
{Path: "paperless", Root: appbackup.RootImport, Role: RoleImport, Label: "a"},
|
||||
{Path: "paperless", Root: appbackup.RootImport, Role: RoleImport, Label: "b"},
|
||||
}
|
||||
if _, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger()); err == nil {
|
||||
t.Error("a duplicate (root, path) must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// data_paths ANNOTATES; it must never be able to declare a path that is not already a compose bind.
|
||||
// This is the property that keeps the design from introducing a new filesystem-write primitive.
|
||||
func TestDataPaths_CannotDeclareNewPaths(t *testing.T) {
|
||||
entries := []DataPath{
|
||||
{Path: "uj-mappa", Root: appbackup.RootUserdata, Role: RoleLibrary, Label: "new"},
|
||||
}
|
||||
_, err := ValidateDataPaths(entries, dpBinds(), "test-app", quietLogger())
|
||||
if err == nil {
|
||||
t.Fatal("data_paths must not be able to name a path with no compose bind behind it")
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end through LoadMetadata: the catalog shape parses, validates, and survives.
|
||||
func TestLoadMetadata_DataPathsRoundTrip(t *testing.T) {
|
||||
compose := `services:
|
||||
calibre-web:
|
||||
image: x
|
||||
volumes:
|
||||
- ${IMPORT_PATH}/calibre:/cwa-book-ingest
|
||||
- ${USERDATA_PATH}/media/books:/calibre-library
|
||||
`
|
||||
meta := `display_name: Calibre-Web
|
||||
slug: calibre-web
|
||||
backup:
|
||||
userdata:
|
||||
- path: media/books
|
||||
class: mandatory
|
||||
import:
|
||||
- path: calibre
|
||||
class: excluded
|
||||
data_paths:
|
||||
- path: calibre
|
||||
root: import
|
||||
role: import
|
||||
label: "Beolvasandó e-könyvek"
|
||||
- path: media/books
|
||||
root: userdata
|
||||
role: library
|
||||
label: "E-könyvtár"
|
||||
`
|
||||
dir := writeApp(t, compose, meta)
|
||||
m := LoadMetadata(dir)
|
||||
if len(m.DataPaths) != 2 {
|
||||
t.Fatalf("expected 2 data_paths, got %d: %+v", len(m.DataPaths), m.DataPaths)
|
||||
}
|
||||
if m.Backup == nil {
|
||||
t.Error("the backup block must survive alongside data_paths")
|
||||
}
|
||||
if m.DataPaths[0].Label != "Beolvasandó e-könyvek" || m.DataPaths[0].Role != RoleImport {
|
||||
t.Errorf("first entry wrong: %+v", m.DataPaths[0])
|
||||
}
|
||||
_ = filepath.Clean
|
||||
}
|
||||
|
||||
// A bad data_paths block must NOT take the backup block down with it — they are validated
|
||||
// independently at the same choke point.
|
||||
func TestLoadMetadata_BadDataPathsKeepsBackupBlock(t *testing.T) {
|
||||
compose := `services:
|
||||
app:
|
||||
image: x
|
||||
volumes:
|
||||
- ${USERDATA_PATH}/media/books:/lib
|
||||
`
|
||||
meta := `display_name: X
|
||||
slug: x
|
||||
backup:
|
||||
userdata:
|
||||
- path: media/books
|
||||
class: mandatory
|
||||
data_paths:
|
||||
- path: /absolute/nope
|
||||
root: userdata
|
||||
role: library
|
||||
label: "bad"
|
||||
`
|
||||
dir := writeApp(t, compose, meta)
|
||||
m := LoadMetadata(dir)
|
||||
if m.DataPaths != nil {
|
||||
t.Errorf("malformed data_paths must be dropped, got %+v", m.DataPaths)
|
||||
}
|
||||
if m.Backup == nil {
|
||||
t.Error("a bad data_paths block must not reject the backup block — they are independent")
|
||||
}
|
||||
}
|
||||
@@ -513,49 +513,32 @@ func buildPathInfo(path string) HDDPath {
|
||||
|
||||
// ParseComposeUserdataMounts reads a docker-compose.yml and extracts the host bind-source paths that
|
||||
// reference ${USERDATA_PATH} (resolved to userdataPath) — the dirs the deploy belt must pre-create
|
||||
// with the userdata convention. Same scanner shape as ParseComposeHDDMounts.
|
||||
// with the userdata convention.
|
||||
//
|
||||
// R-75: this is now a thin RESOLVER over ParseComposeClassifiableBinds, which is the ONE authoritative
|
||||
// compose-bind scanner. The two used to be byte-for-byte duplicate scanners (SPIKE §3) differing only
|
||||
// in what they threw away, so a fix to one silently skipped the other; the classifier won because it
|
||||
// is the richer of the two (it keeps the root and the :ro flag, both of which this function discards
|
||||
// but the classification and derivation paths need).
|
||||
//
|
||||
// ONE deliberate behaviour drop, recorded rather than hidden: the old textual
|
||||
// strings.ReplaceAll("${USERDATA_PATH}", …) + containment check also accepted a bind written as a
|
||||
// LITERAL absolute path that happened to fall under userdataPath. The classifier matches the ${VAR}
|
||||
// reference only. No catalog template has ever used the literal form (verified across all 53 in
|
||||
// SPIKE §2 — every host token is a ${VAR}, a named volume, or the docker socket), and such a compose
|
||||
// would be pinned to one machine's drive layout, so the capability was dead.
|
||||
func ParseComposeUserdataMounts(composePath, userdataPath string) []string {
|
||||
if userdataPath == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(composePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
var out []string
|
||||
for _, b := range ParseComposeClassifiableBinds(composePath) {
|
||||
if b.Root != appbackup.RootUserdata {
|
||||
continue
|
||||
}
|
||||
out = append(out, filepath.Join(userdataPath, filepath.FromSlash(b.RelPath)))
|
||||
}
|
||||
var mounts []string
|
||||
seen := make(map[string]bool)
|
||||
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
|
||||
}
|
||||
hostPath := strings.ReplaceAll(parts[0], "${USERDATA_PATH}", userdataPath)
|
||||
cleanPath := filepath.Clean(hostPath)
|
||||
cleanUD := filepath.Clean(userdataPath)
|
||||
// must be userdataPath itself or a subpath (clean before check — traversal-safe)
|
||||
if cleanPath != cleanUD && !strings.HasPrefix(cleanPath, cleanUD+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
if !seen[cleanPath] {
|
||||
seen[cleanPath] = true
|
||||
mounts = append(mounts, cleanPath)
|
||||
}
|
||||
}
|
||||
return mounts
|
||||
return out
|
||||
}
|
||||
|
||||
// ExportDataMounts returns the host directories a .fab export must capture for an app: the
|
||||
|
||||
@@ -558,18 +558,29 @@ func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...
|
||||
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
|
||||
cmdEnv = withUserdataPath(cmdEnv, env["HDD_PATH"])
|
||||
cmdEnv = withPathVars(cmdEnv, env["HDD_PATH"], m.GetImportRoot())
|
||||
return m.composeExecCustomEnv(dir, cmdEnv, args...)
|
||||
}
|
||||
|
||||
// withUserdataPath appends USERDATA_PATH=<hdd>/userdata to a "K=V" env slice when hdd is non-empty.
|
||||
// withPathVars appends the two derived path variables to a "K=V" env slice:
|
||||
//
|
||||
// USERDATA_PATH=<hdd>/userdata — per-app, on the app's OWN drive (when hdd is non-empty)
|
||||
// IMPORT_PATH=<importRoot> — CANONICAL, on the system drive (when importRoot is non-empty)
|
||||
//
|
||||
// Shared by BOTH compose-env builders (stackEnv for start/redeploy, composeExecWithEnv for the initial
|
||||
// deploy) so ${USERDATA_PATH} always resolves — the initial-deploy path missing it bound a bogus
|
||||
// root-owned dir at the container root.
|
||||
func withUserdataPath(cmdEnv []string, hdd string) []string {
|
||||
// deploy) so the variables always resolve — the initial-deploy path missing USERDATA_PATH bound a
|
||||
// bogus root-owned dir at the container root, and IMPORT_PATH has the identical failure mode.
|
||||
//
|
||||
// An unresolvable importRoot is left UNSET on purpose (the caller logs it): compose then fails loudly
|
||||
// on an unresolved ${IMPORT_PATH} rather than silently falling back to a per-drive path, which would
|
||||
// recreate the dead-drop-zone shape R-75 exists to remove.
|
||||
func withPathVars(cmdEnv []string, hdd, importRoot string) []string {
|
||||
if hdd != "" {
|
||||
cmdEnv = append(cmdEnv, "USERDATA_PATH="+appbackup.UserdataDir(hdd))
|
||||
}
|
||||
if importRoot != "" {
|
||||
cmdEnv = append(cmdEnv, "IMPORT_PATH="+importRoot)
|
||||
}
|
||||
return cmdEnv
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
)
|
||||
|
||||
// R-75 Scenario B — THE regression gate for moving an ingest bind to ${IMPORT_PATH}.
|
||||
//
|
||||
// ValidateBackupSpec rejects an entry whose (Root, RelPath) matches NO compose bind, and the
|
||||
// rejection is WHOLE-BLOCK: LoadMetadata sets Backup back to nil and the app degrades to LEGACY.
|
||||
// So moving paperless's consume bind to ${IMPORT_PATH} while its backup block still said
|
||||
// `userdata: import/paperless` would discard the ENTIRE block — taking
|
||||
// `hdd: appdata/paperless/media class: mandatory` with it. The customer's document originals would
|
||||
// silently fall back to legacy handling. That is the collateral this test exists to catch.
|
||||
//
|
||||
// The fixtures below are copied VERBATIM from app-catalog-felhom.eu templates/paperless-ngx/ (the
|
||||
// bind lines and the whole backup block). If the catalog changes them, this test must be updated in
|
||||
// the same train — that coupling is the point, and Part-8 leg 1 re-checks it against the live
|
||||
// catalog on a real box.
|
||||
|
||||
const importPaperlessCompose = `services:
|
||||
paperless-webserver:
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:2.18.4
|
||||
volumes:
|
||||
- paperless_data:/usr/src/paperless/data
|
||||
- ${HDD_PATH}/appdata/paperless/media:/usr/src/paperless/media
|
||||
- ${HDD_PATH}/appdata/paperless/export:/usr/src/paperless/export
|
||||
- ${IMPORT_PATH}/paperless:/usr/src/paperless/consume
|
||||
volumes:
|
||||
paperless_data:
|
||||
`
|
||||
|
||||
const importPaperlessMeta = `display_name: Paperless-ngx
|
||||
slug: paperless-ngx
|
||||
category: documents
|
||||
backup:
|
||||
hdd:
|
||||
- path: appdata/paperless/media
|
||||
class: mandatory
|
||||
- path: appdata/paperless/export
|
||||
class: excluded
|
||||
import:
|
||||
- path: paperless
|
||||
class: excluded
|
||||
`
|
||||
|
||||
// writeApp lays out a stack dir with a compose file and a .felhom.yml.
|
||||
func writeApp(t *testing.T, compose, meta string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(meta), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestScenarioB_ImportMoveKeepsClassification(t *testing.T) {
|
||||
dir := writeApp(t, importPaperlessCompose, importPaperlessMeta)
|
||||
|
||||
m := LoadMetadata(dir)
|
||||
if m.Backup == nil {
|
||||
t.Fatal("backup block was WHOLE-BLOCK REJECTED (degraded to legacy) — the import move lost the app's classification")
|
||||
}
|
||||
binds := ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml"))
|
||||
classified, has := appbackup.ClassifyBinds(m.Backup, binds)
|
||||
if !has {
|
||||
t.Fatal("hasClassification=false — the app degraded to legacy")
|
||||
}
|
||||
|
||||
type key struct {
|
||||
root appbackup.BindRoot
|
||||
rel string
|
||||
}
|
||||
got := map[key]appbackup.ClassifiedBind{}
|
||||
for _, c := range classified {
|
||||
got[key{c.Root, c.RelPath}] = c
|
||||
}
|
||||
|
||||
want := []struct {
|
||||
root appbackup.BindRoot
|
||||
rel string
|
||||
class appbackup.BindClass
|
||||
origin appbackup.ClassOrigin
|
||||
why string
|
||||
}{
|
||||
{appbackup.RootImport, "paperless", appbackup.ClassExcluded, appbackup.OriginExplicit,
|
||||
"the moved ingest bind must classify under the import root"},
|
||||
{appbackup.RootHDD, "appdata/paperless/media", appbackup.ClassMandatory, appbackup.OriginExplicit,
|
||||
"THE COLLATERAL: a whole-block reject would silently drop this to legacy"},
|
||||
{appbackup.RootHDD, "appdata/paperless/export", appbackup.ClassExcluded, appbackup.OriginExplicit,
|
||||
"second hdd entry must survive too"},
|
||||
}
|
||||
for _, w := range want {
|
||||
c, ok := got[key{w.root, w.rel}]
|
||||
if !ok {
|
||||
t.Errorf("%s/%s: bind missing entirely — %s", w.root, w.rel, w.why)
|
||||
continue
|
||||
}
|
||||
if c.Class != w.class {
|
||||
t.Errorf("%s/%s: class = %q, want %q — %s", w.root, w.rel, c.Class, w.class, w.why)
|
||||
}
|
||||
if c.Origin != w.origin {
|
||||
t.Errorf("%s/%s: origin = %q, want %q — %s", w.root, w.rel, c.Origin, w.origin, w.why)
|
||||
}
|
||||
}
|
||||
// The explicit WRONG outcome from the scenario: nothing may report legacy.
|
||||
for _, c := range classified {
|
||||
if c.Origin == appbackup.OriginLegacy {
|
||||
t.Errorf("%s/%s reported origin=legacy — the block was rejected", c.Root, c.RelPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The companion in the other direction: a STALE `userdata: import/paperless` entry against the new
|
||||
// ${IMPORT_PATH} compose must be REFUSED, and refused WHOLE-BLOCK. This is the state the catalog
|
||||
// would be in if Part 2 moved the compose bind but forgot the backup block — it proves the trap is
|
||||
// real rather than hypothetical, and that the guard catches it rather than silently mis-classifying.
|
||||
func TestScenarioB_StaleUserdataEntryIsWholeBlockRejected(t *testing.T) {
|
||||
staleMeta := `display_name: Paperless-ngx
|
||||
slug: paperless-ngx
|
||||
backup:
|
||||
hdd:
|
||||
- path: appdata/paperless/media
|
||||
class: mandatory
|
||||
userdata:
|
||||
- path: import/paperless
|
||||
class: excluded
|
||||
`
|
||||
dir := writeApp(t, importPaperlessCompose, staleMeta)
|
||||
if m := LoadMetadata(dir); m.Backup != nil {
|
||||
t.Error("a stale userdata entry matching no compose bind must be whole-block rejected")
|
||||
}
|
||||
|
||||
// And prove the consequence the scenario names, so the reject is not mistaken for harmless:
|
||||
// with the block gone, the mandatory hdd path loses its class and goes legacy.
|
||||
m := LoadMetadata(dir)
|
||||
binds := ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml"))
|
||||
classified, has := appbackup.ClassifyBinds(m.Backup, binds)
|
||||
if has {
|
||||
t.Fatal("precondition: block should be nil here")
|
||||
}
|
||||
for _, c := range classified {
|
||||
if c.Root == appbackup.RootHDD && c.RelPath == "appdata/paperless/media" {
|
||||
if c.Origin != appbackup.OriginLegacy || c.Class != "" {
|
||||
t.Errorf("expected the collateral to be legacy/unclassed, got class=%q origin=%q", c.Class, c.Origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// calibre-web carries BOTH an import bind and a userdata library bind — the multi-root case.
|
||||
func TestScenarioB_CalibreBothRoots(t *testing.T) {
|
||||
compose := `services:
|
||||
calibre-web:
|
||||
image: crocodilestick/calibre-web-automated:v4.0.6
|
||||
volumes:
|
||||
- calibre_web_config:/config
|
||||
- ${IMPORT_PATH}/calibre:/cwa-book-ingest
|
||||
- ${USERDATA_PATH}/media/books:/calibre-library
|
||||
volumes:
|
||||
calibre_web_config:
|
||||
`
|
||||
meta := `display_name: Calibre-Web
|
||||
slug: calibre-web
|
||||
backup:
|
||||
userdata:
|
||||
- path: media/books
|
||||
class: mandatory
|
||||
import:
|
||||
- path: calibre
|
||||
class: excluded
|
||||
`
|
||||
dir := writeApp(t, compose, meta)
|
||||
m := LoadMetadata(dir)
|
||||
if m.Backup == nil {
|
||||
t.Fatal("calibre-web backup block was whole-block rejected")
|
||||
}
|
||||
classified, has := appbackup.ClassifyBinds(m.Backup, ParseComposeClassifiableBinds(filepath.Join(dir, "docker-compose.yml")))
|
||||
if !has {
|
||||
t.Fatal("calibre-web degraded to legacy")
|
||||
}
|
||||
seen := map[string]appbackup.ClassifiedBind{}
|
||||
for _, c := range classified {
|
||||
seen[string(c.Root)+"/"+c.RelPath] = c
|
||||
}
|
||||
if c := seen["import/calibre"]; c.Class != appbackup.ClassExcluded || c.Origin != appbackup.OriginExplicit {
|
||||
t.Errorf("import/calibre: class=%q origin=%q, want excluded/explicit", c.Class, c.Origin)
|
||||
}
|
||||
if c := seen["userdata/media/books"]; c.Class != appbackup.ClassMandatory || c.Origin != appbackup.OriginExplicit {
|
||||
t.Errorf("userdata/media/books: class=%q origin=%q, want mandatory/explicit", c.Class, c.Origin)
|
||||
}
|
||||
}
|
||||
|
||||
// The import root resolves against the SYSTEM drive, never the app's own drive. Two apps on two
|
||||
// different drives must resolve their ingest folders to the SAME parent — the canonical property.
|
||||
func TestImportBindResolvesToSystemDrive(t *testing.T) {
|
||||
const importRoot = "/mnt/sys_drive/felhom-data/userdata/import"
|
||||
binds := []appbackup.ClassifiedBind{
|
||||
{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootImport, RelPath: "paperless"},
|
||||
Class: appbackup.ClassExcluded, Origin: appbackup.OriginExplicit},
|
||||
}
|
||||
for _, hdd := range []string{"/mnt/felhom-drives/hdd_1", "/mnt/felhom-drives/nvme-1tb"} {
|
||||
fb := appbackup.ComputeFabBuckets(binds, true, hdd, importRoot)
|
||||
if len(fb.Excluded) != 1 {
|
||||
t.Fatalf("hdd=%s: expected 1 excluded bucket entry, got %d (skipped=%v)", hdd, len(fb.Excluded), fb.Skipped)
|
||||
}
|
||||
if got, want := fb.Excluded[0].Abs, importRoot+"/paperless"; got != want {
|
||||
t.Errorf("hdd=%s: import bind resolved to %q, want %q — it must NOT follow the app's drive", hdd, got, want)
|
||||
}
|
||||
}
|
||||
// Unresolvable import root ⇒ refused LOUDLY into Skipped, never joined onto "".
|
||||
fb := appbackup.ComputeFabBuckets(binds, true, "/mnt/felhom-drives/hdd_1", "")
|
||||
if len(fb.Excluded) != 0 {
|
||||
t.Errorf("an unresolvable import root must not resolve: %+v", fb.Excluded)
|
||||
}
|
||||
if len(fb.Skipped) != 1 {
|
||||
t.Fatalf("expected the bind in Skipped, got %+v", fb.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// R-75 Scenario A — the deploy belt puts the drop-zone on the SYSTEM drive and NOWHERE ELSE.
|
||||
// The wrong outcome this guards is a second, non-functional import/<app> appearing on the data
|
||||
// drive: it would look exactly like a drop-zone, silently do nothing, and (import being
|
||||
// class: excluded) never be backed up either.
|
||||
func TestScenarioA_BeltCreatesImportOnSystemDriveOnly(t *testing.T) {
|
||||
m := newMigManager(t, "")
|
||||
stackDir := writeApp(t, importPaperlessCompose, importPaperlessMeta)
|
||||
|
||||
dataDrive := t.TempDir() // stands in for /mnt/felhom-drives/hdd_1
|
||||
sysNS := t.TempDir() // stands in for /mnt/sys_drive/felhom-data
|
||||
userdataPath := appbackup.UserdataDir(dataDrive)
|
||||
importPath := appbackup.ImportDir(sysNS)
|
||||
m.isMountPoint = func(string) bool { return true } // the data drive is attached
|
||||
|
||||
m.ensureUserdataMounts(stackDir, []string{
|
||||
"HDD_PATH=" + dataDrive,
|
||||
"USERDATA_PATH=" + userdataPath,
|
||||
"IMPORT_PATH=" + importPath,
|
||||
})
|
||||
|
||||
// (1) the drop-zone exists on the SYSTEM drive
|
||||
want := filepath.Join(importPath, "paperless")
|
||||
fi, err := os.Stat(want)
|
||||
if err != nil || !fi.IsDir() {
|
||||
t.Fatalf("belt did not create the canonical drop-zone %s (%v)", want, err)
|
||||
}
|
||||
// (2) with the userdata convention: setgid + group-rwx
|
||||
if perm := fi.Mode().Perm(); perm != 0o775 || fi.Mode()&os.ModeSetgid == 0 {
|
||||
t.Errorf("drop-zone mode = %v, want setgid + 0775 (2775)", fi.Mode())
|
||||
}
|
||||
// (3) and NOT on the data drive — the wrong outcome named in the scenario
|
||||
if _, err := os.Stat(filepath.Join(userdataPath, "import")); err == nil {
|
||||
t.Errorf("a second drop-zone was created on the data drive at %s — exactly the dead drop-zone R-75 removes",
|
||||
filepath.Join(userdataPath, "import"))
|
||||
}
|
||||
}
|
||||
|
||||
// A detached data drive must not stop the system-drive drop-zone from being created: the two roots
|
||||
// are on different devices and the drive-absent gate is about the data drive only.
|
||||
func TestScenarioA_ImportBeltNotGatedByDetachedDataDrive(t *testing.T) {
|
||||
m := newMigManager(t, "")
|
||||
stackDir := writeApp(t, importPaperlessCompose, importPaperlessMeta)
|
||||
dataDrive := t.TempDir()
|
||||
sysNS := t.TempDir()
|
||||
importPath := appbackup.ImportDir(sysNS)
|
||||
m.isMountPoint = func(string) bool { return false } // drive DETACHED
|
||||
|
||||
m.ensureUserdataMounts(stackDir, []string{
|
||||
"HDD_PATH=" + dataDrive,
|
||||
"USERDATA_PATH=" + appbackup.UserdataDir(dataDrive),
|
||||
"IMPORT_PATH=" + importPath,
|
||||
})
|
||||
|
||||
if _, err := os.Stat(filepath.Join(importPath, "paperless")); err != nil {
|
||||
t.Errorf("the system-drive drop-zone must be created even when the DATA drive is detached: %v", err)
|
||||
}
|
||||
// the userdata half stays correctly gated (nothing written onto the rootfs)
|
||||
if _, err := os.Stat(appbackup.UserdataDir(dataDrive)); err == nil {
|
||||
t.Error("the drive-absent gate must still suppress userdata creation on a detached drive")
|
||||
}
|
||||
}
|
||||
|
||||
// R-75: the canonical drop-zone must never migrate with an app. Migrating an app OFF the system
|
||||
// drive would otherwise drag <sysNS>/userdata/import onto the destination data drive — a second,
|
||||
// non-functional, unbacked drop-zone.
|
||||
func TestImportRootExcludedFromMigration(t *testing.T) {
|
||||
m := newMigManager(t, "")
|
||||
sysNS := appbackup.NamespaceRoot(m.cfg.Paths.SystemDataPath, false)
|
||||
importRoot := appbackup.ImportDir(sysNS)
|
||||
|
||||
// App migrating OFF the system drive: source namespace IS the system namespace.
|
||||
offSystem := m.appDataSkipSet(&MigrationJob{SourceNS: sysNS, Apps: []string{"paperless-ngx"}})
|
||||
if !offSystem[filepath.Clean(importRoot)] {
|
||||
t.Errorf("import root %q must be pruned from a migration off the system drive; skip set = %v",
|
||||
importRoot, offSystem)
|
||||
}
|
||||
|
||||
// App migrating OFF a data drive: no import root there, nothing extra to prune.
|
||||
dataNS := "/mnt/felhom-drives/hdd_1"
|
||||
offData := m.appDataSkipSet(&MigrationJob{SourceNS: dataNS, Apps: []string{"paperless-ngx"}})
|
||||
if offData[filepath.Clean(importRoot)] {
|
||||
t.Error("a data-drive migration must not carry a system-drive skip entry")
|
||||
}
|
||||
}
|
||||
|
||||
// pathUnder must be segment-wise: a sibling sharing a name prefix is NOT contained.
|
||||
func TestPathUnderIsSegmentWise(t *testing.T) {
|
||||
root := filepath.Clean("/mnt/sys_drive")
|
||||
if !pathUnder(root, root) {
|
||||
t.Error("a path must be under itself")
|
||||
}
|
||||
if !pathUnder(filepath.Join(root, "felhom-data", "userdata"), root) {
|
||||
t.Error("a descendant must be under the root")
|
||||
}
|
||||
if pathUnder(filepath.Clean("/mnt/sys_drive-evil/x"), root) {
|
||||
t.Error("a name-prefix sibling must NOT be under the root")
|
||||
}
|
||||
}
|
||||
@@ -150,10 +150,12 @@ func (m *Manager) ensureFileBrowser(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
// Initial render: no storage mounts yet (web.SyncFileBrowserMounts fills them in on the first
|
||||
// storage-path change and owns all later regeneration).
|
||||
// Initial render: no storage mounts yet, and no import source either (web.SyncFileBrowserMounts
|
||||
// fills both in on the first storage-path change and owns all later regeneration). Rendering the
|
||||
// import source here without its bind would produce a source with no path behind it — a broken
|
||||
// sidebar entry — so both halves are deliberately deferred to the same place.
|
||||
compose := infra.RenderFileBrowserCompose(m.cfg.Customer.Domain, nil)
|
||||
config := infra.RenderFileBrowserConfig(nil)
|
||||
config := infra.RenderFileBrowserConfig(nil, false)
|
||||
if err := os.WriteFile(composePath, []byte(compose), 0o644); err != nil {
|
||||
return fmt.Errorf("write docker-compose.yml: %w", err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,6 +51,11 @@ type Metadata struct {
|
||||
// block (sets this back to nil + logs one ERROR) on any validation defect, so a bad catalog push
|
||||
// degrades to legacy loudly rather than partially classifying. Consumed by Task 3/4 — INERT today.
|
||||
Backup *appbackup.BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"`
|
||||
// DataPaths is the customer-facing folder ANNOTATION (R-75): role + Hungarian label over paths
|
||||
// that must ALREADY exist as compose binds. It never declares a path. Validated in LoadMetadata
|
||||
// with the Fork-3 asymmetry — a malformed PATH rejects the whole block, an unknown ROLE drops
|
||||
// just that entry (see ValidateDataPaths).
|
||||
DataPaths []DataPath `yaml:"data_paths,omitempty" json:"data_paths,omitempty"`
|
||||
}
|
||||
|
||||
// SMTPMapping renames the generic relay settings (host / port / security / from / from-name)
|
||||
@@ -328,15 +333,32 @@ func LoadMetadata(stackDir string) Metadata {
|
||||
// block exists) the WHOLE block is rejected — meta.Backup = nil, one ERROR — so the app degrades
|
||||
// to legacy (today's behavior) rather than partially classifying. INERT: nothing consumes
|
||||
// meta.Backup yet (Task 3/4).
|
||||
if meta.Backup != nil {
|
||||
if meta.Backup != nil || len(meta.DataPaths) > 0 {
|
||||
composePath := filepath.Join(stackDir, "docker-compose.yml")
|
||||
binds := ParseComposeClassifiableBinds(composePath)
|
||||
if _, err := os.Stat(composePath); err != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, err)
|
||||
meta.Backup = nil
|
||||
} else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
|
||||
meta.Backup = nil
|
||||
_, composeErr := os.Stat(composePath)
|
||||
if meta.Backup != nil {
|
||||
if composeErr != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, composeErr)
|
||||
meta.Backup = nil
|
||||
} else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
|
||||
meta.Backup = nil
|
||||
}
|
||||
}
|
||||
// data_paths (R-75) rides the same choke point, with its own asymmetric rules: a malformed
|
||||
// path drops the WHOLE block (nothing from it can be trusted), an unknown role drops only
|
||||
// that entry (presentation, not data handling).
|
||||
if len(meta.DataPaths) > 0 {
|
||||
if composeErr != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml data_paths rejected in %s: docker-compose.yml unreadable: %v", stackDir, composeErr)
|
||||
meta.DataPaths = nil
|
||||
} else if kept, err := ValidateDataPaths(meta.DataPaths, binds, dirName, log.Default()); err != nil {
|
||||
log.Printf("[ERROR] [stacks] .felhom.yml data_paths rejected in %s: %v", stackDir, err)
|
||||
meta.DataPaths = nil
|
||||
} else {
|
||||
meta.DataPaths = kept
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -652,7 +652,8 @@ func (m *Manager) migCleanupAllowed(j *MigrationJob) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// appDataSkipSet returns the source appdata dirs (rsync'd separately) to skip in the merge walk.
|
||||
// appDataSkipSet returns the source dirs to prune from the merge walk: the app appdata dirs (rsync'd
|
||||
// separately) plus the canonical import root (R-75, never migrates).
|
||||
func (m *Manager) appDataSkipSet(j *MigrationJob) map[string]bool {
|
||||
skip := map[string]bool{}
|
||||
for _, app := range j.Apps {
|
||||
@@ -660,9 +661,25 @@ func (m *Manager) appDataSkipSet(j *MigrationJob) map[string]bool {
|
||||
skip[filepath.Clean(appbackup.AppDataDir(j.SourceNS, name))] = true
|
||||
}
|
||||
}
|
||||
// R-75: the CANONICAL drop-zone lives ONCE, on the system drive, and must never move with an app.
|
||||
// This fires when an app is migrated OFF the system drive: the merge walk would otherwise see
|
||||
// <sysNS>/userdata/import in the source namespace and copy the whole box's drop-zone onto the
|
||||
// destination data drive — creating exactly the second, non-functional drop-zone this arc exists
|
||||
// to remove (and, import being class: excluded, an unbacked one). Migrations off a DATA drive are
|
||||
// unaffected: a data drive has no import root to match.
|
||||
if root := m.GetImportRoot(); root != "" && pathUnder(root, j.SourceNS) {
|
||||
skip[filepath.Clean(root)] = true
|
||||
}
|
||||
return skip
|
||||
}
|
||||
|
||||
// pathUnder reports whether p is root or lives beneath it. Segment-wise, so a sibling directory
|
||||
// sharing a name prefix can never match.
|
||||
func pathUnder(p, root string) bool {
|
||||
cp, cr := filepath.Clean(p), filepath.Clean(root)
|
||||
return cp == cr || strings.HasPrefix(cp, cr+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// RecoverMigration resumes a crashed migration on startup (no-op if none or terminal).
|
||||
func (m *Manager) RecoverMigration(ctx context.Context) {
|
||||
j, err := m.loadJournal()
|
||||
|
||||
@@ -55,8 +55,8 @@ func TestSambaClassifiedBinds_TierMembership(t *testing.T) {
|
||||
}
|
||||
|
||||
// Tier membership through the real helper.
|
||||
offsite := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, storageRoot)
|
||||
secondary := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, storageRoot)
|
||||
offsite := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, storageRoot, "")
|
||||
secondary := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, storageRoot, "")
|
||||
|
||||
if !hasRel(offsite, "shares/dokumentumok") {
|
||||
t.Errorf("mandatory share must be in the OFFSITE set: %+v", offsite.Paths)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
)
|
||||
|
||||
// Catalog-derived userdata skeleton (R-75).
|
||||
//
|
||||
// Before this, the customer-facing tree was a hardcoded Go list, so a new catalog app with a folder
|
||||
// needed a controller release. The set is now DERIVED from the catalog the controller has already
|
||||
// synced to disk — using the same authoritative parser the classifier uses — merged with the
|
||||
// carry-list (appbackup.UserdataSkeletonCarry) so it can only ever ADD.
|
||||
//
|
||||
// Scope note: this derives ${USERDATA_PATH} binds only. ${IMPORT_PATH} binds are NOT part of a
|
||||
// drive's skeleton — the canonical drop-zone lives once, on the system drive, and is ensured by
|
||||
// EnsureImportRoot instead.
|
||||
|
||||
// DeriveUserdataDirs returns the ${USERDATA_PATH}-relative dirs implied by every template in
|
||||
// stacksDir. ALL catalog apps count, not just deployed ones (Fork-2 ruling): the skeleton is
|
||||
// storage-path-scoped and idempotent, and the all-apps set is barely larger than the historical
|
||||
// hardcoded one (SPIKE P0(a): 14 vs 14, differing by one entry each way). Deployed-only filtering
|
||||
// belongs in the UI, where an empty folder would actually confuse someone.
|
||||
//
|
||||
// Unreadable dirs / missing composes are skipped silently — a partially-synced catalog must degrade
|
||||
// to "fewer derived dirs", never to an error that blocks the skeleton (the carry-list is the floor).
|
||||
// The result is sorted; BuildUserdataSkeleton sorts again after merging, so both layers are pinned.
|
||||
func DeriveUserdataDirs(stacksDir string) []string {
|
||||
entries, err := os.ReadDir(stacksDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
set := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
composePath := filepath.Join(stacksDir, e.Name(), "docker-compose.yml")
|
||||
for _, b := range ParseComposeClassifiableBinds(composePath) {
|
||||
if b.Root != appbackup.RootUserdata || b.RelPath == "" {
|
||||
continue
|
||||
}
|
||||
set[b.RelPath] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for d := range set {
|
||||
out = append(out, d)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// UserdataSkeletonDirs is the merged, sorted set to create on a storage path: catalog-derived plus
|
||||
// the carry-list. This is THE function every skeleton caller should use.
|
||||
func (m *Manager) UserdataSkeletonDirs() []string {
|
||||
return appbackup.BuildUserdataSkeleton(DeriveUserdataDirs(m.cfg.Paths.StacksDir))
|
||||
}
|
||||
|
||||
// EnsureUserdataSkeleton applies the merged skeleton to a storage path's namespace root. Signature
|
||||
// kept as func(string) error so it drops straight into fbPathDeps.ensureSkeleton.
|
||||
func (m *Manager) EnsureUserdataSkeleton(nsRoot string) error {
|
||||
return appbackup.EnsureUserdataSkeleton(nsRoot, m.UserdataSkeletonDirs())
|
||||
}
|
||||
|
||||
// EnsureImportRoot creates the CANONICAL drop-zone root on the system drive with the userdata
|
||||
// convention, so it exists (and is browsable + shareable) even before any drop-zone app is deployed.
|
||||
// Idempotent; a no-op when the import root is unresolvable.
|
||||
//
|
||||
// It deliberately does NOT pre-create per-app subfolders: those are the deploy belt's job, so an app
|
||||
// the customer never installed does not litter the drop-zone with an empty folder.
|
||||
func (m *Manager) EnsureImportRoot() error {
|
||||
root := m.GetImportRoot()
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
return appbackup.EnsureUserdataDir(root)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package stacks
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
@@ -72,11 +74,12 @@ func TestEnsureUserdataMounts_CreatesBeltDirs(t *testing.T) {
|
||||
_ = appbackup.SharedContentGID // keep import referenced cross-platform
|
||||
}
|
||||
|
||||
// TestWithUserdataPath: the shared injector adds USERDATA_PATH=<hdd>/userdata when HDD_PATH is set, and
|
||||
// TestWithPathVars: the shared injector adds USERDATA_PATH=<hdd>/userdata when HDD_PATH is set, and
|
||||
// adds nothing when it's empty. Regression for the initial-deploy bug where ${USERDATA_PATH} resolved
|
||||
// to "" and bound a bogus root-owned dir at the container root.
|
||||
func TestWithUserdataPath(t *testing.T) {
|
||||
got := withUserdataPath([]string{"DOMAIN=x"}, "/mnt/felhom-usb")
|
||||
func TestWithPathVars(t *testing.T) {
|
||||
const importRoot = "/mnt/sys_drive/felhom-data/userdata/import"
|
||||
got := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-usb", importRoot)
|
||||
want := "USERDATA_PATH=" + appbackup.UserdataDir("/mnt/felhom-usb")
|
||||
found := false
|
||||
for _, e := range got {
|
||||
@@ -88,9 +91,39 @@ func TestWithUserdataPath(t *testing.T) {
|
||||
t.Errorf("USERDATA_PATH not injected: got %v, want %q", got, want)
|
||||
}
|
||||
// companion: empty HDD_PATH → no USERDATA_PATH at all
|
||||
for _, e := range withUserdataPath([]string{"DOMAIN=x"}, "") {
|
||||
if len(e) >= 13 && e[:13] == "USERDATA_PATH" {
|
||||
for _, e := range withPathVars([]string{"DOMAIN=x"}, "", importRoot) {
|
||||
if strings.HasPrefix(e, "USERDATA_PATH") {
|
||||
t.Errorf("USERDATA_PATH must NOT be set when HDD_PATH is empty: %q", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithPathVars_ImportPath pins the R-75 half. IMPORT_PATH has the SAME failure mode
|
||||
// USERDATA_PATH had: a site that forgets it resolves ${IMPORT_PATH} to "" and binds a bogus
|
||||
// root-owned dir at the container root. And the unresolvable case must leave the variable UNSET —
|
||||
// never fall back to a per-drive path, which would recreate the dead-drop-zone shape R-75 removes.
|
||||
func TestWithPathVars_ImportPath(t *testing.T) {
|
||||
const importRoot = "/mnt/sys_drive/felhom-data/userdata/import"
|
||||
|
||||
got := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/hdd_1", importRoot)
|
||||
if !slices.Contains(got, "IMPORT_PATH="+importRoot) {
|
||||
t.Errorf("IMPORT_PATH not injected: got %v", got)
|
||||
}
|
||||
// It is CANONICAL: it must not be derived from HDD_PATH. A second app on a different drive gets
|
||||
// the identical value — that is the whole point of the canonical root.
|
||||
other := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/nvme-1tb", importRoot)
|
||||
if !slices.Contains(other, "IMPORT_PATH="+importRoot) {
|
||||
t.Errorf("IMPORT_PATH must not vary with HDD_PATH: got %v", other)
|
||||
}
|
||||
for _, e := range got {
|
||||
if strings.HasPrefix(e, "IMPORT_PATH=") && strings.Contains(e, "felhom-drives") {
|
||||
t.Errorf("IMPORT_PATH must never point at a data drive: %q", e)
|
||||
}
|
||||
}
|
||||
// Unresolvable → UNSET (compose then fails loudly on ${IMPORT_PATH}).
|
||||
for _, e := range withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/hdd_1", "") {
|
||||
if strings.HasPrefix(e, "IMPORT_PATH") {
|
||||
t.Errorf("IMPORT_PATH must NOT be set when the import root is unresolvable: %q", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user