From 2958946517847db8ff3a0fdd429967fa4d340b3a Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 26 Jul 2026 08:12:57 +0200 Subject: [PATCH] =?UTF-8?q?v0.172.0=20=E2=80=94=20R-75:=20canonical=20impo?= =?UTF-8?q?rt=20root,=20catalog-derived=20skeleton,=20import=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ${IMPORT_PATH} = /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/` 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. --- CHANGELOG.md | 79 +++++ CONTEXT.md | 32 +- REUSE.md | 6 +- controller/README.md | 24 +- controller/cmd/controller/main.go | 10 + .../internal/api/backup_snapshots_test.go | 1 + controller/internal/appbackup/appdata.go | 6 +- controller/internal/appbackup/captureset.go | 48 ++- .../internal/appbackup/captureset_test.go | 16 +- controller/internal/appbackup/classify.go | 70 +++- .../internal/appbackup/fabbuckets_test.go | 10 +- .../appbackup/skeleton_determinism_test.go | 134 ++++++++ controller/internal/appbackup/userdata.go | 82 ++++- .../internal/appbackup/userdata_test.go | 11 +- .../appexport/estimate_volsize_test.go | 1 + controller/internal/appexport/fabplan.go | 4 +- controller/internal/appexport/fabplan_test.go | 1 + controller/internal/appexport/provider.go | 3 + .../internal/appexport/roundtrip_test.go | 1 + controller/internal/backup/offbox_3a_test.go | 1 + controller/internal/backup/offbox_capture.go | 2 +- .../internal/backup/recovery_unit_test.go | 1 + controller/internal/backup/tier2_capture.go | 2 +- .../internal/backup/tier2_restore_test.go | 1 + controller/internal/backup/tier2_v2_test.go | 1 + .../internal/backup/volume_dumps_test.go | 1 + controller/internal/infra/infra.go | 23 +- controller/internal/infra/infra_test.go | 6 +- controller/internal/settings/smb.go | 21 ++ .../internal/stacks/captureset_wiring_test.go | 4 +- controller/internal/stacks/classify_binds.go | 5 + controller/internal/stacks/datapaths.go | 99 ++++++ controller/internal/stacks/datapaths_test.go | 176 ++++++++++ controller/internal/stacks/delete.go | 59 ++-- controller/internal/stacks/deploy.go | 21 +- .../stacks/import_root_classify_test.go | 325 ++++++++++++++++++ controller/internal/stacks/infra.go | 8 +- controller/internal/stacks/manager.go | 68 +++- controller/internal/stacks/metadata.go | 36 +- controller/internal/stacks/migrate.go | 19 +- .../internal/stacks/samba_classify_test.go | 4 +- controller/internal/stacks/skeleton_derive.go | 81 +++++ .../internal/stacks/userdata_belt_test.go | 43 ++- controller/internal/web/async_restore_test.go | 1 + controller/internal/web/datapath_card.go | 139 ++++++++ controller/internal/web/datapath_card_test.go | 117 +++++++ controller/internal/web/fab_export_test.go | 1 + controller/internal/web/filebrowser_link.go | 64 ++++ .../internal/web/filebrowser_link_test.go | 95 +++++ .../internal/web/filebrowser_network_test.go | 8 +- controller/internal/web/handlers.go | 31 +- controller/internal/web/import_share_test.go | 220 ++++++++++++ controller/internal/web/sharing_handlers.go | 79 ++++- controller/internal/web/storage_handlers.go | 18 +- .../internal/web/templates/app_info.html | 18 + .../internal/web/templates/sharing.html | 4 + controller/internal/web/templates/style.css | 38 ++ 57 files changed, 2228 insertions(+), 151 deletions(-) create mode 100644 controller/internal/appbackup/skeleton_determinism_test.go create mode 100644 controller/internal/stacks/datapaths.go create mode 100644 controller/internal/stacks/datapaths_test.go create mode 100644 controller/internal/stacks/import_root_classify_test.go create mode 100644 controller/internal/stacks/skeleton_derive.go create mode 100644 controller/internal/web/datapath_card.go create mode 100644 controller/internal/web/datapath_card_test.go create mode 100644 controller/internal/web/filebrowser_link.go create mode 100644 controller/internal/web/filebrowser_link_test.go create mode 100644 controller/internal/web/import_share_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 129c1e1..6292b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,84 @@ ## Changelog +### v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces (2026-07-26) + +Spike: `felhom.eu/documentation/audits/SPIKE-catalog-data-paths-2026-07-26.md`. + +**The drop-zone is now ONE canonical location on the system drive.** New `${IMPORT_PATH}` = +`/userdata/import`, injected at BOTH compose-env builders +(`withUserdataPath` → `withPathVars`, `deploy.go` + `manager.go`) — the initial-deploy path missing +`USERDATA_PATH` once bound a bogus root-owned dir at the container root, and `IMPORT_PATH` has the +identical failure mode. It is derived from the SYSTEM drive, never from `HDD_PATH`, and has **no +per-drive fallback**: an unresolvable root leaves the variable UNSET so compose fails loudly instead +of quietly building a second, non-functional drop-zone. *Operator ruling, overriding the spike's +Fork-1 recommendation:* each drop-zone app has exactly one ingest bind, so a per-drive `import/` +would put a folder that LOOKS like a drop-zone on every drive while only one works — and since +import paths are `class: excluded`, files stranded in a dead one are never backed up either. + +**Third `BindRoot` + the whole-block regression it prevents.** `RootImport` / `${IMPORT_PATH}` in +`composeVarRoots`, an `Import []BindSpec` list in `BackupSpec`, and `ValidateBackupSpec` / +`ClassifyBinds` extended. This is load-bearing: `ValidateBackupSpec` rejects an entry matching no +compose bind and the rejection is WHOLE-BLOCK, so moving paperless's ingest bind while leaving +`userdata: import/paperless` in place would have discarded the entire block — taking +`hdd: appdata/paperless/media class: mandatory` with it and silently degrading the customer's +document originals to legacy handling. `TestScenarioB_*` is the gate. + +**Exhaustive-root audit — `resolveAbs` was the sharp one.** An import bind resolved against `hddPath` +would name a directory on the WRONG DRIVE. `resolveAbs`, `structuralGuard`, `ComputeCaptureSet` and +`ComputeFabBuckets` now take `importRoot` explicitly (compile-forced at all 4 call sites), and an +unresolvable root is refused LOUDLY into `Skipped` (`reasonNoImportRoot`) rather than joined onto "". +`GetImportRoot()` added to both provider interfaces + both adapters. `fabplan`/`tier2DestRel`/ +`export.go`/`appbackup_bridge.go` audited and recorded in REPORT.md. + +**Catalog-derived skeleton, deterministic by construction.** `UserdataSkeleton()` → +`UserdataSkeletonCarry()` (the v0.171.0 list verbatim, retained forever) + `BuildUserdataSkeleton()`, +which merges it with `DeriveUserdataDirs(stacksDir)` and **sorts**. The carry-list makes zero-removals +true by construction — `documents` is implied by no catalog app yet exists on both demo boxes — and +doubles as the fresh-box floor. The sort is not tidiness: the spike measured the naive map-order +derivation at **20 distinct outputs from 20 identical runs**, and `fbNeedsRecreate` force-recreates on +any byte difference across ~14 `SyncFileBrowserMounts` call sites — a fleet-wide FileBrowser restart +loop. `TestScenarioC_SkeletonDeterminism` pins 20/20. The catalog sync is deliberately **still not** +wired to `SyncFileBrowserMounts`. The canonical import root is excluded from per-app migration +(`appDataSkipSet`) so it never moves with an app. + +**One authoritative compose parser.** `ParseComposeUserdataMounts` is now a thin resolver over +`ParseComposeClassifiableBinds`. The classifier won because it is the richer of the two byte-identical +scanners (it keeps the root and the `:ro` flag). One deliberate behaviour drop, recorded not hidden: +the old textual replace also accepted a LITERAL absolute path under `userdataPath`; no catalog +template has ever used that form and such a compose would be pinned to one machine's drive layout. +The deploy belt now handles both roots, gated differently — the drive-absent gate applies to the app's +data drive and must NOT suppress a system-drive import dir. + +**Surfaces.** FileBrowser gains a separate `/srv/beolvasas` bind + a „Beolvasás" sidebar source +(separate, not nested — a nested source is indexed twice). New app-page block **„Hova tegyem a +fájlokat?"** for DEPLOYED apps declaring `data_paths`, with a deep link built from the shipped +Quantum router template, `url.PathEscape` per segment (**never `QueryEscape`** — it encodes space as +`+`, a literal plus in a path), the system-drive free space on import rows, and a **class-driven** +consequence line so the UI can never promise a backup the engines do not make. Copy does not promise +one click: a cold deep link goes through the FileBrowser login. + +**`data_paths:` annotation** (`stacks.Metadata.DataPaths`) — role + Hungarian label over paths that +must ALREADY exist as compose binds; it can never declare one. Fork-3 asymmetry, deliberate: a +malformed PATH is a whole-block reject (data handling; reuses `ValidateBackupSpec`'s refusal set via +the extracted `appbackup.ValidateRelPath` — no second validator), an unknown ROLE fails OPEN with one +WARN (presentation; the `Lifecycle` precedent). Catalog: paperless-ngx, calibre-web, romm. + +**System-owned import share.** `SMBShare.System`; a `beolvasas` share auto-created when sharing is +ENABLED (never before — deploying an app must not put SMB on the household LAN), `Offsite: false` +because the data is `class: excluded`. Deletion refused **server-side at both the handler and the +store**, and the button omitted in the template — three checks proving different things (the v0.70.1 +ghost-delete lesson: a render gate is not enforcement, a handler test is not reachability). The share +is written directly rather than through `sharingResolvePath`: that guard validates CUSTOMER-supplied +picker paths, and the system drive is deliberately not a registered StoragePath. + +**A latent 500 caught on the way:** the sharing template's row struct was function-local, so adding +`{{if .System}}` would have failed at render for every share. `ShareRow` is now package-level and the +render test constructs the exact type the handler passes. + +Tests 915 → 949, all green. Red-proofs recorded in REPORT.md for Scenario B (classification), +C (determinism) and E (server-side share refusal). No destructive filesystem call was added anywhere +in this arc. **MinAgent unchanged.** + ### v0.171.0 — Disk-health card: device-model label (pairs with agent v0.95.0) (2026-07-25) `agentapi.SmartSummary` gains `ModelName` (mirrors the agent v0.95.0 `model_name`); the "Lemezek diff --git a/CONTEXT.md b/CONTEXT.md index 6d07825..5e6eda9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,37 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-24 (v0.169.0 — disk-health card + degradation notification "Lemezek állapota") +Last updated: 2026-07-26 (v0.172.0 — R-75 canonical import root + catalog-derived skeleton) + +> **2026-07-26 — v0.172.0 (R-75).** Spike `felhom.eu/documentation/audits/SPIKE-catalog-data-paths-2026-07-26.md`; +> feature doc `felhom.eu/documentation/controller/import-and-data-paths.md`. +> +> **RULING — the import root is CANONICAL on the system drive, overriding the spike's Fork-1 +> recommendation of per-drive roots.** The spike weighed sidebar clutter and per-app link ambiguity and +> concluded per-drive; the operator overruled it on an argument the spike missed: each drop-zone app has +> exactly ONE ingest bind, so on a two-drive box every import folder except the app's own would look like +> a drop-zone and silently do nothing — and because `import/*` is `class: excluded`, files stranded there +> are never backed up either. A canonical root is the only shape with no dead drop-zone. Recorded as a +> deliberate deviation, not an oversight. +> +> **Phase-0 probe changed the shape of Part 6.** The system drive is NOT a registered `StoragePath` on +> either demo box (`/mnt/felhom-drives/hdd_1` on demo-felhom; `nvme-1tb` + `Felhom-Share` on demo-hp), +> so `sharingResolvePath` REFUSES `/userdata/import` — verified against the real guard with a +> passing control. Registering the drive was rejected (it would make the 50 GB volume holding the +> recovery units a customer-visible drive, deploy target and wipe candidate, and `SharingDeniedRoots` +> would then deny the namespace-consistent shape anyway). **Chosen: leave it unregistered and have the +> controller write the `beolvasas` share directly** — the picker guard validates CUSTOMER-supplied paths, +> a controller-generated constant is a different trust class. No guard was weakened. +> +> Also note: `withUserdataPath` computes `USERDATA_PATH` as `/userdata`, NOT +> `NamespaceRoot(hdd)/userdata`. For an app on the system drive those disagree +> (`/mnt/sys_drive/userdata` vs the `felhom-data` namespace). Latent — no app with a userdata bind has +> ever been deployed there — but it is a real inconsistency, left untouched here. +> +> The other three forks followed the spike unchanged: all-apps skeleton / deployed-only in the UI; +> unknown role fails OPEN while a malformed path whole-block rejects; drop-zone copy driven by the +> derived backup class. + > **2026-07-24 — v0.169.0 (disk-health card + degradation alert).** Consumes the agent's new `smart` > field (agent v0.94.0; MinAgent floor unchanged — feature-detect by presence). **Rulings:** (1) ONE diff --git a/REUSE.md b/REUSE.md index 4df4d69..df0340e 100644 --- a/REUSE.md +++ b/REUSE.md @@ -14,7 +14,11 @@ | `PrimaryBackupPath` / `RecoveryUnitPath` / `RecoveryUnitComposePath` / `RecoveryUnitManifestPath` | controller/internal/appbackup/paths.go | `(nsRoot[, stackName]) string` | All backup dir layout | Take the NAMESPACE ROOT, not a bare drive path | | `AppDBDumpPath` / `AppVolumeDumpPath` / `AppDataDir` | controller/internal/appbackup/paths.go | `(nsRoot, stackName) string` | Per-app dump/data dirs | Same nsRoot contract. `AppDataDir`'s final segment is the app's real appdata dir NAME — NOT always the stack name (paperless-ngx → `paperless`); resolve via `AppDataDirNames` first (F-S2/F-S3) | | `AppDataDirNames` / `AppDataBindsPresent` | controller/internal/appbackup/paths.go | `(hddPath, stackName string, hddMounts []string) []string` / `(hddPath, hddMounts) bool` | Resolve the real `appdata/` dir(s) from compose `${HDD_PATH}` binds (F-S2/F-S3) | `hddMounts` = ParseComposeHDDMounts shape. Deduped+sorted; falls back to `[stackName]` when no appdata bind. Tier-2 (`backup.Manager.tier2AppDataName`) refuses N>1; migrate (`stacks.Manager.ResolveAppDataDirNames`) loops N. `BindsPresent` drives the WARN-on-missing-declared-dir | -| `UserdataDir` / `EnsureUserdataSkeleton` / `EnsureDirOwned` | controller/internal/appbackup/userdata.go | `(nsRoot)` / `(path, gid int)` | userdata/ tree w/ 2775 setgid gid-1000 convention | Linux-only chown via build-tag twin userdata_linux.go | +| `UserdataDir` / `ImportDir` / `EnsureUserdataSkeleton` / `EnsureDirOwned` | controller/internal/appbackup/userdata.go | `(nsRoot)` / `(nsRoot)` / `(nsRoot, dirs []string)` / `(path, gid int)` | userdata/ tree w/ 2775 setgid gid-1000 convention. **R-75:** `ImportDir` is the CANONICAL drop-zone (`/userdata/import`) and callers MUST resolve it against the SYSTEM namespace, never an app's HDD_PATH — use `stacks.Manager.GetImportRoot()`. `EnsureUserdataSkeleton` now takes the dir set: build it with `BuildUserdataSkeleton(DeriveUserdataDirs(stacksDir))`, or via `Manager.EnsureUserdataSkeleton` / `web.Server.ensureUserdataSkeleton`. | Linux-only chown via build-tag twin userdata_linux.go. **The set MUST stay sorted** — `fbNeedsRecreate` force-recreates FileBrowser on any byte diff and the naive map-order derivation measured 20/20 distinct (SPIKE P6). `UserdataSkeletonCarry()` is the old hardcoded list, retained forever so derivation can only ADD (zero removals). | +| `BuildUserdataSkeleton` / `UserdataSkeletonCarry` / `DeriveUserdataDirs` | appbackup/userdata.go, stacks/skeleton_derive.go | `([]string)` / `()` / `(stacksDir)` | catalog-derived userdata skeleton (R-75) | Derives `${USERDATA_PATH}` binds only — `${IMPORT_PATH}` is NOT part of a drive skeleton (one root, system drive, `Manager.EnsureImportRoot`). Do NOT wire the catalog sync to `SyncFileBrowserMounts`. | +| `appbackup.ValidateRelPath` / `ValidRoot` | controller/internal/appbackup/classify.go | `(root, path)` / `(root)` | THE single path-safety refusal set for every `${VAR}`-relative catalog path | Shared by `backup:` and `data_paths:`. **Do not write a second path validator.** | +| `stacks.ValidateDataPaths` | controller/internal/stacks/datapaths.go | `(entries, binds, appName, logger)` | `data_paths:` annotation validation | ASYMMETRIC on purpose (Fork-3): malformed PATH ⇒ whole-block reject (data handling, `backup:` precedent); unknown ROLE ⇒ fails OPEN, one WARN (presentation, `Lifecycle` precedent). | +| `web.fileBrowserLink` / `importFolderLink` | controller/internal/web/filebrowser_link.go | `(domain, sourceName, relPath)` | FileBrowser Quantum deep link | Template read out of the shipped router (SPIKE P2). **`url.PathEscape` per segment — NEVER `QueryEscape`** (space→`+` is a literal plus in a path). Let `html/template` do the attribute escaping; do not pre-escape. | | `HumanizeBytes` | controller/internal/appbackup/appdata.go | `(b int64) string` | Human byte sizes | Exported canonical; private clones exist (§6) | | `stablePathForName` / `agentWhere` | controller/internal/web/intermediary.go | `(name/registeredPath) string` | Map registry stable path `/mnt/felhom-drives/` ↔ raw agent mount | Registry stores STABLE path; agent ops take the RAW mount — always convert | | `offsiteRestoreRootFor` | controller/internal/backup/offbox_verify_copies.go | `(drivePath string) string` | THE only place `backups/offsite-restore` is spelled | `offboxRestoreScratchDir` builds on it — the listing/delete surface MUST resolve byte-identical paths to what the restore wrote. Do not re-hardcode the segments (they were open-coded in 3 places before v0.147.0) | diff --git a/controller/README.md b/controller/README.md index ec81348..6a1540b 100644 --- a/controller/README.md +++ b/controller/README.md @@ -393,8 +393,18 @@ Each app can define rich metadata in `.felhom.yml`: surfaces it on `/apps/{slug}` as a "Kezdeti belépési adatok" card (masked password + reveal/copy), labelled as the *initial* credential (stays valid only until the customer changes it in-app). Hidden when the container is down / file missing / parse fails. Reuse for any future self-seeding app. -- `backup` (v0.132.0): the **referential-coupling classification** block (Task 2). Two optional lists, - `userdata:` (relative to `${USERDATA_PATH}`) and `hdd:` (relative to `${HDD_PATH}`), each of +- `data_paths` (v0.172.0, R-75): the **customer-facing folder annotation** — `{path, root, role, label}` + where `root ∈ {import, userdata, hdd}` and `role ∈ {import, library, export}`. It ANNOTATES paths that + must already exist as compose binds and can never declare one (so no new filesystem-write primitive + comes from catalog data). Validation is deliberately ASYMMETRIC: a malformed **path** is a whole-block + reject (reusing `appbackup.ValidateRelPath`, the same refusal set as `backup:`), an unknown **role** + fails OPEN with one WARN (the `Lifecycle` precedent — presentation, not data handling). Rendered on + `/apps/{slug}` as „Hova tegyem a fájlokat?" for DEPLOYED apps only, each row a FileBrowser deep link + plus a **class-driven** consequence line. Full contract: + `felhom.eu/documentation/controller/import-and-data-paths.md`. +- `backup` (v0.132.0; **three** lists since v0.172.0): the **referential-coupling classification** block + (Task 2). Optional lists `userdata:` (relative to `${USERDATA_PATH}`), `hdd:` (relative to + `${HDD_PATH}`) and `import:` (relative to `${IMPORT_PATH}`, R-75), each of `{path, class}` where `class ∈ {mandatory, optional, excluded}` (COUPLED / DECOUPLED-precious / DECOUPLED-bulk). `LoadMetadata` validates the block against the app's compose binds and **rejects the WHOLE block** (→ nil + one `[ERROR]`, app behaves as legacy) on any defect. Semantics @@ -501,6 +511,16 @@ The `/apps/{slug}` page renders hero section, screenshots, setup guide, and opti #### Base-infrastructure bring-up (`stacks/infra.go` + `internal/infra/`, v0.41.0) +**Canonical import root (v0.172.0, R-75).** `${IMPORT_PATH}` = `/userdata/import` +— ONE drop-zone per box, on the always-available system drive, never per data drive (each drop-zone app +has exactly one ingest bind, so a per-drive `import/` would put a dead lookalike on every other drive, +and `import/*` is `class: excluded` so files stranded there are unbacked too). Injected at BOTH +compose-env builders; **no per-drive fallback** — unresolvable leaves it unset so compose fails loudly. +The system drive is deliberately NOT a registered `StoragePath`, so the FileBrowser bind +(`/srv/beolvasas`, sidebar „Beolvasás"), the skeleton and the system-owned `beolvasas` SMB share each +reach it explicitly. The **userdata skeleton is catalog-derived** (`DeriveUserdataDirs` + +`UserdataSkeletonCarry`, sorted — the sort is load-bearing, see REUSE.md) and can only ever ADD. + The controller stands up its own base stack — **traefik** (reverse proxy), **cloudflared** (external tunnel), **filebrowser** — instead of relying on the bare-metal `scripts/docker-setup.sh` (which a Proxmox-provisioned guest never runs). `internal/infra` renders the compose + config files from `controller.yaml` via embedded `text/template`s (lifted from `docker-setup.sh`); image tags are **pinned constants there** (`TraefikImage`/`CloudflaredImage`/`FileBrowserImage`) and the web FileBrowser sync path delegates to the same renderers, so the pinned versions can never diverge. `Manager.EnsureBaseStack()` creates the `traefik-public` network, then deploys traefik → cloudflared → filebrowser under `${stacks_dir}/`. It is: diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 7f64eac..ca17fbc 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -1328,6 +1328,11 @@ func (a *stackAdapter) GetDockerVolumes(name string) []string { return backup.ResolveDockerVolumeNames(s.ComposePath) } +// GetImportRoot delegates to the stack manager's canonical drop-zone root (R-75). Both the backup +// and the export provider need it: ${IMPORT_PATH} binds live on the SYSTEM drive and can NOT be +// resolved from an app's HDD_PATH. +func (a *stackAdapter) GetImportRoot() string { return a.mgr.GetImportRoot() } + func (a *stackAdapter) GetStackHDDPath(name string) string { s, ok := a.mgr.GetStack(name) if !ok { @@ -1543,6 +1548,11 @@ func (a *exportAdapter) GetStackHDDMounts(name string) []string { return nil } +// GetImportRoot delegates to the stack manager's canonical drop-zone root (R-75). Both the backup +// and the export provider need it: ${IMPORT_PATH} binds live on the SYSTEM drive and can NOT be +// resolved from an app's HDD_PATH. +func (a *exportAdapter) GetImportRoot() string { return a.mgr.GetImportRoot() } + func (a *exportAdapter) GetStackHDDPath(name string) string { s, ok := a.mgr.GetStack(name) if !ok { diff --git a/controller/internal/api/backup_snapshots_test.go b/controller/internal/api/backup_snapshots_test.go index b6bff93..bf70248 100644 --- a/controller/internal/api/backup_snapshots_test.go +++ b/controller/internal/api/backup_snapshots_test.go @@ -27,6 +27,7 @@ func (p *snapshotsStubProvider) GetStackComposePath(name string) (string, bool) func (p *snapshotsStubProvider) ListDeployedStacks() []backup.StackSummary { return nil } func (p *snapshotsStubProvider) GetStackHDDMounts(string) []string { return nil } func (p *snapshotsStubProvider) GetStackHDDPath(string) string { return p.hdd } +func (p *snapshotsStubProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (p *snapshotsStubProvider) GetDockerVolumes(string) []string { return nil } func (p *snapshotsStubProvider) StopStack(string) error { return nil } func (p *snapshotsStubProvider) StartStack(string) error { return nil } diff --git a/controller/internal/appbackup/appdata.go b/controller/internal/appbackup/appdata.go index c5e54cb..b8ff70d 100644 --- a/controller/internal/appbackup/appdata.go +++ b/controller/internal/appbackup/appdata.go @@ -19,7 +19,11 @@ type StackDataProvider interface { GetStackComposePath(name string) (composePath string, ok bool) ListDeployedStacks() []StackSummary GetStackHDDMounts(name string) []string - GetStackHDDPath(name string) string // raw HDD_PATH from app.yaml (empty if no HDD) + GetStackHDDPath(name string) string // raw HDD_PATH from app.yaml (empty if no HDD) + // GetImportRoot returns the CANONICAL drop-zone root (R-75): /userdata/import. + // It is app-INDEPENDENT and lives on the SYSTEM drive, so ${IMPORT_PATH} binds cannot be resolved + // from GetStackHDDPath. Empty when unresolvable — structuralGuard refuses such binds loudly. + GetImportRoot() string GetDockerVolumes(name string) []string // full Docker volume names (project-prefixed) StopStack(name string) error StartStack(name string) error diff --git a/controller/internal/appbackup/captureset.go b/controller/internal/appbackup/captureset.go index fc7cd8d..1ef59f4 100644 --- a/controller/internal/appbackup/captureset.go +++ b/controller/internal/appbackup/captureset.go @@ -59,6 +59,8 @@ const ( reasonEscape = "path escapes the drive root" reasonBareRoot = "bare drive-root bind would capture the backups tree" reasonReserved = "path inside the reserved backups zone" + // reasonNoImportRoot: a ${IMPORT_PATH} bind with no resolvable system namespace root (R-75). + reasonNoImportRoot = "canonical import root unresolvable (system_data_path unconfigured)" ) // ComputeCaptureSet resolves an app's classified binds into the tier-filtered absolute capture set. @@ -71,16 +73,17 @@ const ( // so the engines' no-block branch stays byte-identical to today (the SQ5 cost-regression guard). // // Resolution: RootHDD → path.Join(hddPath, relPath); RootUserdata → path.Join(hddPath, "userdata", -// relPath). Guards run AFTER the tier filter, so Skipped means exactly "would have been captured by -// this tier, refused for structural safety". -func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier CaptureTier, hddPath string) CaptureSet { +// relPath); RootImport → path.Join(importRoot, relPath) — the SYSTEM drive, never hddPath (R-75). +// Guards run AFTER the tier filter, so Skipped means exactly "would have been captured by this tier, +// refused for structural safety". +func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier CaptureTier, hddPath, importRoot string) CaptureSet { if !hasClassification { return CaptureSet{HasClassification: false} } cs := CaptureSet{HasClassification: true} // Stages 1–3: tier filter → structural guards → equal-Abs collapse (shared with ComputeFabBuckets). - uniq, skipped := resolveGuardCollapse(binds, hddPath, func(c BindClass) bool { return tierKeeps(tier, c) }) + uniq, skipped := resolveGuardCollapse(binds, hddPath, importRoot, func(c BindClass) bool { return tierKeeps(tier, c) }) cs.Skipped = skipped // Stage 4: containment dedup — drop any path whose ancestor is already present (keep the ancestor). @@ -107,18 +110,18 @@ func ComputeCaptureSet(binds []ClassifiedBind, hasClassification bool, tier Capt // collapse (mandatory > optional > excluded; ties by smaller Root/RelPath). It does NOT apply // containment dedup — the caller decides (ComputeCaptureSet does; ComputeFabBuckets must not, so a // mandatory child inside an excluded parent stays independently addressable). -func resolveGuardCollapse(binds []ClassifiedBind, hddPath string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) { +func resolveGuardCollapse(binds []ClassifiedBind, hddPath, importRoot string, keep func(BindClass) bool) (uniq []CapturePath, skipped []SkippedPath) { var resolved []CapturePath for _, b := range binds { if !keep(b.Class) { continue } - if reason, bad := structuralGuard(b.Root, b.RelPath); bad { + if reason, bad := structuralGuard(b.Root, b.RelPath, importRoot); bad { skipped = append(skipped, SkippedPath{Root: b.Root, RelPath: b.RelPath, Class: b.Class, Reason: reason}) continue } resolved = append(resolved, CapturePath{ - Abs: resolveAbs(hddPath, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class, + Abs: resolveAbs(hddPath, importRoot, b.Root, b.RelPath), Root: b.Root, RelPath: b.RelPath, Class: b.Class, }) } byAbs := make(map[string]CapturePath, len(resolved)) @@ -153,12 +156,12 @@ type FabBuckets struct { // selection UI + plan. Same resolution + structural guards + equal-Abs collapse as ComputeCaptureSet // (via resolveGuardCollapse), bucketed by class, no cross-bucket containment dedup. Each bucket is // Abs-sorted (deterministic). -func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath string) FabBuckets { +func ComputeFabBuckets(binds []ClassifiedBind, hasClassification bool, hddPath, importRoot string) FabBuckets { if !hasClassification { return FabBuckets{HasClassification: false} } fb := FabBuckets{HasClassification: true} - uniq, skipped := resolveGuardCollapse(binds, hddPath, func(BindClass) bool { return true }) + uniq, skipped := resolveGuardCollapse(binds, hddPath, importRoot, func(BindClass) bool { return true }) fb.Skipped = skipped for _, cp := range uniq { switch cp.Class { @@ -201,10 +204,19 @@ func tierKeeps(tier CaptureTier, class BindClass) bool { // there (ParseComposeClassifiableBinds path.Cleans; ValidateBackupSpec vets only SPEC entries), so an // unlisted writable "${HDD_PATH}/../x" bind reaches here classed mandatory — this guard is // load-bearing security, not defence-in-depth. -func structuralGuard(root BindRoot, relPath string) (reason string, bad bool) { +func structuralGuard(root BindRoot, relPath, importRoot string) (reason string, bad bool) { if relPathEscapes(relPath) { return reasonEscape, true } + // RootImport (R-75) resolves against the SYSTEM drive, not hddPath. If that root is unresolvable + // (system_data_path unconfigured) the bind cannot be placed at all — refuse it LOUDLY into Skipped + // rather than let resolveAbs join onto "" and produce a relative, wrong-drive path. The other two + // roots cannot hit this: hddPath is checked by their own callers. + if root == RootImport && importRoot == "" { + return reasonNoImportRoot, true + } + // A bare ${IMPORT_PATH} bind is allowed: it resolves to /userdata/import, which nests no + // backups/ tree (backups live at /backups, a sibling of userdata). if root == RootHDD { if relPath == "" { return reasonBareRoot, true // bare ${HDD_PATH} would nest /backups into the capture @@ -233,11 +245,21 @@ func relPathEscapes(relPath string) bool { } // resolveAbs maps a guarded (root, relPath) to its in-container absolute path via slash algebra. -func resolveAbs(hddPath string, root BindRoot, relPath string) string { - if root == RootUserdata { +// +// RootImport is the one root that does NOT resolve against hddPath: the canonical drop-zone lives on +// the SYSTEM drive (R-75), so importRoot is supplied separately by the caller. Resolving it against +// hddPath would silently name a directory on the WRONG DRIVE — a .fab opt-in would then capture (or +// on restore, write) somewhere that merely looks plausible. An empty importRoot is the unresolvable +// case and is refused upstream by structuralGuard, never silently joined. +func resolveAbs(hddPath, importRoot string, root BindRoot, relPath string) string { + switch root { + case RootUserdata: return path.Join(hddPath, "userdata", relPath) + case RootImport: + return path.Join(importRoot, relPath) + default: + return path.Join(hddPath, relPath) } - return path.Join(hddPath, relPath) } // strongerCapture picks the winner of an equal-Abs collision: mandatory beats optional; on equal diff --git a/controller/internal/appbackup/captureset_test.go b/controller/internal/appbackup/captureset_test.go index 52361ca..e387450 100644 --- a/controller/internal/appbackup/captureset_test.go +++ b/controller/internal/appbackup/captureset_test.go @@ -38,12 +38,12 @@ func TestComputeCaptureSet_PerTierSplit(t *testing.T) { {ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/photos", ReadOnly: true}, Class: ClassOptional, Origin: OriginExplicit}, } - off := ComputeCaptureSet(binds, true, TierOffsite, drv) + off := ComputeCaptureSet(binds, true, TierOffsite, drv, "") if got, want := absList(off), []string{hdd("appdata/immich")}; !reflect.DeepEqual(got, want) { t.Errorf("offsite Paths = %v, want %v (mandatory only — the :ro optional must NOT ship offsite)", got, want) } - sec := ComputeCaptureSet(binds, true, TierSecondary, drv) + sec := ComputeCaptureSet(binds, true, TierSecondary, drv, "") want := []string{hdd("appdata/immich"), udat("media/photos")} if got := absList(sec); !reflect.DeepEqual(got, want) { t.Errorf("secondary Paths = %v, want %v (sorted)", got, want) @@ -72,7 +72,7 @@ func TestComputeCaptureSet_LegacyInert(t *testing.T) { {ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/sonarr"}, Origin: OriginLegacy}, } for _, tier := range []CaptureTier{TierOffsite, TierSecondary} { - cs := ComputeCaptureSet(binds, false, tier, drv) + cs := ComputeCaptureSet(binds, false, tier, drv, "") if cs.HasClassification { t.Errorf("%s: HasClassification=true for a legacy app", tier) } @@ -94,7 +94,7 @@ func TestComputeCaptureSet_ExcludedInvisible(t *testing.T) { {ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "import/paperless"}, Class: ClassExcluded, Origin: OriginExplicit}, } for _, tier := range []CaptureTier{TierOffsite, TierSecondary} { - cs := ComputeCaptureSet(binds, true, tier, drv) + cs := ComputeCaptureSet(binds, true, tier, drv, "") if got, want := absList(cs), []string{hdd("appdata/paperless/media")}; !reflect.DeepEqual(got, want) { t.Errorf("%s Paths = %v, want %v (excluded filtered)", tier, got, want) } @@ -113,7 +113,7 @@ func TestComputeCaptureSet_StructuralGuards(t *testing.T) { {ComposeBind: ComposeBind{Root: RootHDD, RelPath: "backups/primary/x"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d3 reserved zone {ComposeBind: ComposeBind{Root: RootUserdata, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d4 bare userdata — ALLOWED } - cs := ComputeCaptureSet(binds, true, TierOffsite, drv) + cs := ComputeCaptureSet(binds, true, TierOffsite, drv, "") // Paths: ONLY d4's userdata root — no escaped root, no backups/ anywhere. if got, want := absList(cs), []string{udat("")}; !reflect.DeepEqual(got, want) { @@ -156,7 +156,7 @@ func TestComputeCaptureSet_LegitDotDotName(t *testing.T) { binds := []ClassifiedBind{ {ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/a..b"}, Class: ClassMandatory, Origin: OriginExplicit}, } - cs := ComputeCaptureSet(binds, true, TierOffsite, drv) + cs := ComputeCaptureSet(binds, true, TierOffsite, drv, "") if got, want := absList(cs), []string{hdd("appdata/a..b")}; !reflect.DeepEqual(got, want) { t.Errorf("Paths = %v, want %v (a..b is a legit name, not traversal)", got, want) } @@ -174,7 +174,7 @@ func TestComputeCaptureSet_ContainmentAndCollision(t *testing.T) { {ComposeBind: ComposeBind{Root: RootHDD, RelPath: "userdata/media"}, Class: ClassOptional, Origin: OriginExplicit}, // Abs collides with next {ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassMandatory, Origin: OriginExplicit}, // same Abs, mandatory } - cs := ComputeCaptureSet(binds, true, TierSecondary, drv) + cs := ComputeCaptureSet(binds, true, TierSecondary, drv, "") want := []string{hdd("appdata/paperless"), udat("media")} if got := absList(cs); !reflect.DeepEqual(got, want) { @@ -186,7 +186,7 @@ func TestComputeCaptureSet_ContainmentAndCollision(t *testing.T) { } // determinism: recompute and compare full struct - cs2 := ComputeCaptureSet(binds, true, TierSecondary, drv) + cs2 := ComputeCaptureSet(binds, true, TierSecondary, drv, "") if !reflect.DeepEqual(cs, cs2) { t.Error("ComputeCaptureSet is non-deterministic across runs") } diff --git a/controller/internal/appbackup/classify.go b/controller/internal/appbackup/classify.go index 7398062..f9a9a25 100644 --- a/controller/internal/appbackup/classify.go +++ b/controller/internal/appbackup/classify.go @@ -34,12 +34,23 @@ type BindRoot string const ( RootUserdata BindRoot = "userdata" // relative to ${USERDATA_PATH} RootHDD BindRoot = "hdd" // relative to ${HDD_PATH} + // RootImport is relative to ${IMPORT_PATH} — the CANONICAL drop-zone root (R-75). Unlike the + // other two it does NOT resolve against the app's own drive: it lives on the system drive's + // namespace, so every app's ingest folder is in one place. Resolvers therefore need the import + // root passed in separately; they cannot derive it from hddPath. + RootImport BindRoot = "import" ) // BackupSpec is the .felhom.yml `backup:` block. Paths are forward-slash, relative, path.Clean'd. type BackupSpec struct { Userdata []BindSpec `yaml:"userdata,omitempty" json:"userdata,omitempty"` HDD []BindSpec `yaml:"hdd,omitempty" json:"hdd,omitempty"` + // Import classifies ${IMPORT_PATH}-relative binds (R-75). An app whose ingest bind moved from + // ${USERDATA_PATH}/import/ to ${IMPORT_PATH}/ MUST move its backup entry here in the + // same change: ValidateBackupSpec rejects an entry matching no compose bind, and the rejection is + // WHOLE-BLOCK, so a stale `userdata: import/` would discard the app's OTHER classifications + // (e.g. an hdd appdata path classed mandatory) and silently degrade it to legacy. + Import []BindSpec `yaml:"import,omitempty" json:"import,omitempty"` } // BindSpec is one classified entry in a BackupSpec. @@ -86,6 +97,42 @@ func validClass(c BindClass) bool { } } +// ValidateRelPath is THE path-safety refusal set for every ${VAR}-relative catalog path — the +// `backup:` block and `data_paths:` both run through it, so there is exactly ONE definition of what +// a safe relative path is. Refuses: empty, backslash, absolute, non-path.Clean'd, and any leading +// ".." escape. It deliberately does NOT check "matches a compose bind" — that rule needs the bind +// list and differs per caller (whole-block reject for backup:, per-entry for data_paths:). +func ValidateRelPath(root BindRoot, p string) error { + where := fmt.Sprintf("%s[%q]", root, p) + if p == "" { + return fmt.Errorf("%s: empty path", where) + } + if strings.ContainsRune(p, '\\') { + return fmt.Errorf("%s: backslash in path (paths are forward-slash relative)", where) + } + if path.IsAbs(p) { + return fmt.Errorf("%s: absolute path (must be relative to the %s root)", where, root) + } + if p != path.Clean(p) { + return fmt.Errorf("%s: non-clean path (want %q)", where, path.Clean(p)) + } + // path.Clean has run — ".." can only survive as a leading "../" segment. + if p == ".." || strings.HasPrefix(p, "../") { + return fmt.Errorf("%s: path escapes the root (..)", where) + } + return nil +} + +// ValidRoot reports whether r is one of the three known bind roots. +func ValidRoot(r BindRoot) bool { + switch r { + case RootUserdata, RootHDD, RootImport: + return true + default: + return false + } +} + // ValidateBackupSpec checks a parsed backup block against the app's actual compose binds and returns // the FIRST defect (whole-block semantics — the caller rejects the ENTIRE block on any error, so the // app degrades to legacy rather than partially classifying). A nil spec is vacuously valid (legacy). @@ -113,21 +160,8 @@ func ValidateBackupSpec(spec *BackupSpec, binds []ComposeBind) error { if !validClass(e.Class) { return fmt.Errorf("%s: invalid class %q (want mandatory|optional|excluded)", where, e.Class) } - if e.Path == "" { - return fmt.Errorf("%s: empty path", where) - } - if strings.ContainsRune(e.Path, '\\') { - return fmt.Errorf("%s: backslash in path (paths are forward-slash relative)", where) - } - if path.IsAbs(e.Path) { - return fmt.Errorf("%s: absolute path (must be relative to the %s root)", where, root) - } - if e.Path != path.Clean(e.Path) { - return fmt.Errorf("%s: non-clean path (want %q)", where, path.Clean(e.Path)) - } - // path.Clean has run — ".." can only survive as a leading "../" segment. - if e.Path == ".." || strings.HasPrefix(e.Path, "../") { - return fmt.Errorf("%s: path escapes the root (..)", where) + if err := ValidateRelPath(root, e.Path); err != nil { + return err } key := string(root) + "\x00" + e.Path if seen[key] { @@ -143,7 +177,10 @@ func ValidateBackupSpec(spec *BackupSpec, binds []ComposeBind) error { if err := check(RootUserdata, spec.Userdata); err != nil { return err } - return check(RootHDD, spec.HDD) + if err := check(RootHDD, spec.HDD); err != nil { + return err + } + return check(RootImport, spec.Import) } // ClassifyBinds resolves every compose bind to a class + origin, applying the two-level default. The @@ -181,6 +218,7 @@ func ClassifyBinds(spec *BackupSpec, binds []ComposeBind) (classified []Classifi } add(RootUserdata, spec.Userdata) add(RootHDD, spec.HDD) + add(RootImport, spec.Import) for _, b := range binds { cb := ClassifiedBind{ComposeBind: b} diff --git a/controller/internal/appbackup/fabbuckets_test.go b/controller/internal/appbackup/fabbuckets_test.go index 5ab00f8..d116ebc 100644 --- a/controller/internal/appbackup/fabbuckets_test.go +++ b/controller/internal/appbackup/fabbuckets_test.go @@ -21,7 +21,7 @@ func TestComputeFabBuckets_Classified(t *testing.T) { {ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/movies"}, Class: ClassExcluded}, {ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/app"}, Class: ClassMandatory}, } - fb := ComputeFabBuckets(binds, true, drv) + fb := ComputeFabBuckets(binds, true, drv, "") if !fb.HasClassification { t.Fatal("HasClassification must be true") } @@ -39,7 +39,7 @@ func TestComputeFabBuckets_Classified(t *testing.T) { // legacy (no block) → empty buckets (the full-root capture stays out of the classified plan). func TestComputeFabBuckets_LegacyEmpty(t *testing.T) { binds := []ClassifiedBind{{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/tv"}, Origin: OriginLegacy}} - fb := ComputeFabBuckets(binds, false, drv) + fb := ComputeFabBuckets(binds, false, drv, "") if fb.HasClassification || fb.Mandatory != nil || fb.Optional != nil || fb.Excluded != nil { t.Errorf("legacy app must yield empty buckets, got %+v", fb) } @@ -52,7 +52,7 @@ func TestComputeFabBuckets_GuardsAllClasses(t *testing.T) { {ComposeBind: ComposeBind{Root: RootHDD, RelPath: "../evil"}, Class: ClassExcluded}, {ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/ok"}, Class: ClassMandatory}, } - fb := ComputeFabBuckets(binds, true, drv) + fb := ComputeFabBuckets(binds, true, drv, "") for _, b := range [][]CapturePath{fb.Mandatory, fb.Optional, fb.Excluded} { for _, p := range b { if p.RelPath == "../evil" { @@ -71,7 +71,7 @@ func TestComputeFabBuckets_NoCrossBucketContainment(t *testing.T) { {ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassExcluded}, {ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/books"}, Class: ClassMandatory}, } - fb := ComputeFabBuckets(binds, true, drv) + fb := ComputeFabBuckets(binds, true, drv, "") if got, want := bucketAbs(fb.Mandatory), []string{udat("media/books")}; !reflect.DeepEqual(got, want) { t.Errorf("mandatory child must survive independently: Mandatory = %v, want %v", got, want) } @@ -86,7 +86,7 @@ func TestComputeFabBuckets_EqualAbsMandatoryWins(t *testing.T) { {ComposeBind: ComposeBind{Root: RootHDD, RelPath: "userdata/media"}, Class: ClassOptional}, {ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassMandatory}, } - fb := ComputeFabBuckets(binds, true, drv) + fb := ComputeFabBuckets(binds, true, drv, "") if got, want := bucketAbs(fb.Mandatory), []string{udat("media")}; !reflect.DeepEqual(got, want) { t.Errorf("collapsed path must land in Mandatory, got Mandatory=%v", got) } diff --git a/controller/internal/appbackup/skeleton_determinism_test.go b/controller/internal/appbackup/skeleton_determinism_test.go new file mode 100644 index 0000000..a27e431 --- /dev/null +++ b/controller/internal/appbackup/skeleton_determinism_test.go @@ -0,0 +1,134 @@ +package appbackup + +import ( + "os" + "path/filepath" + "slices" + "testing" +) + +// R-75 Scenario C — DETERMINISM. This is the P6 gate. +// +// The spike measured the naive map-order derivation producing 20 DISTINCT outputs from 20 identical +// runs. fbNeedsRecreate force-recreates FileBrowser on ANY byte difference in the generated config, +// and SyncFileBrowserMounts has ~14 call sites — so a non-deterministic skeleton is a fleet-wide +// FileBrowser restart loop, the v0.151-class bug. 20 identical generations or this fails. +func TestScenarioC_SkeletonDeterminism(t *testing.T) { + // Deliberately UNSORTED input, with duplicates and a deep path, so the function has real work to + // normalise. A sort applied only to the input would not save a map-ordered implementation. + derived := []string{ + "media/podcasts", "roms", "media/books", "downloads", "media", + "media/photos", "media/books", "a/b/c/d", + } + const n = 20 + first := BuildUserdataSkeleton(derived) + for i := 1; i < n; i++ { + got := BuildUserdataSkeleton(derived) + if !slices.Equal(got, first) { + t.Fatalf("generation %d/%d differs — a non-deterministic skeleton force-recreates FileBrowser on every sync pass\n first: %v\n got: %v", + i+1, n, first, got) + } + } + if !slices.IsSorted(first) { + t.Errorf("skeleton must be sorted, got %v", first) + } + // Ancestor expansion: a deep derived path implies its whole chain. + for _, want := range []string{"a", "a/b", "a/b/c", "a/b/c/d"} { + if !slices.Contains(first, want) { + t.Errorf("ancestor chain incomplete: %q missing from %v", want, first) + } + } + // Dedup: "media/books" appeared twice in the input and "media" both derived and as an ancestor. + for _, d := range []string{"media", "media/books"} { + if c := countOf(first, d); c != 1 { + t.Errorf("%q appears %d times, want exactly 1", d, c) + } + } +} + +func countOf(xs []string, want string) int { + n := 0 + for _, x := range xs { + if x == want { + n++ + } + } + return n +} + +// R-75 Scenario D — ZERO REMOVALS, proven by construction. +// +// The derived set drops `documents` (implied by no catalog app) and, after the R-75 move, the two +// import/* entries. The carry-list is what keeps them. This asserts the merged set is a strict +// SUPERSET of the historical hardcoded skeleton for any derived input — including the empty one, the +// fresh-box case where the catalog has not synced yet. +func TestScenarioD_SkeletonNeverDropsACarriedDir(t *testing.T) { + for _, derived := range [][]string{ + nil, // fresh box, catalog not yet synced + {"media/podcasts"}, // the one genuinely new entry + {"roms", "downloads", "media/photos"}, // a partial catalog + } { + got := BuildUserdataSkeleton(derived) + for _, carried := range UserdataSkeletonCarry() { + if !slices.Contains(got, carried) { + t.Errorf("derived=%v: carried dir %q was DROPPED — zero-removals violated", derived, carried) + } + } + } + // And the new entry really is added when the catalog implies it. + if !slices.Contains(BuildUserdataSkeleton([]string{"media/podcasts"}), "media/podcasts") { + t.Error("media/podcasts must be added when the catalog implies it") + } + // `documents` is the specific entry the spike flagged: in the carry-list, in no catalog app. + if !slices.Contains(BuildUserdataSkeleton([]string{"media/podcasts"}), "documents") { + t.Error("`documents` must survive — it exists on both demo boxes and may hold customer files") + } +} + +// A traversal or absolute entry reaching the skeleton would make EnsureUserdataSkeleton create a +// directory outside the userdata root. The derived set comes from a compose parser, so this is a +// guard on untrusted-ish catalog input, not defence in depth. +func TestSkeletonRefusesEscapes(t *testing.T) { + got := BuildUserdataSkeleton([]string{"../escape", "..", "", "/abs/path", "ok/dir"}) + for _, bad := range []string{"../escape", "..", "", "/abs/path"} { + if slices.Contains(got, bad) { + t.Errorf("escape entry %q must not reach the skeleton: %v", bad, got) + } + } + for _, d := range got { + if filepath.IsAbs(d) || d == ".." || len(d) > 3 && d[:3] == "../" { + t.Errorf("unsafe skeleton entry %q", d) + } + } + if !slices.Contains(got, "ok/dir") { + t.Error("a legitimate entry alongside bad ones must still be kept") + } + // "/abs/path" is not dropped outright — it is normalised to a relative path and kept, which is + // safe (it lands under the userdata root). Pin that so the behaviour is a decision, not a guess. + if !slices.Contains(got, "abs/path") { + t.Errorf("an absolute entry should be normalised to relative, got %v", got) + } +} + +// EnsureUserdataSkeleton creates every dir it is given and NOTHING ELSE, and never removes. +func TestEnsureUserdataSkeletonCreatesOnly(t *testing.T) { + ns := t.TempDir() + // A pre-existing customer dir that no catalog app implies and the carry-list does not contain. + stray := filepath.Join(UserdataDir(ns), "sajat-mappa") + if err := os.MkdirAll(stray, 0o755); err != nil { + t.Fatal(err) + } + dirs := BuildUserdataSkeleton([]string{"media/podcasts"}) + if err := EnsureUserdataSkeleton(ns, dirs); err != nil { + // chown to gid 1000 fails for a non-root test user; the dirs are still created. + t.Logf("EnsureUserdataSkeleton returned %v (expected when not running as root)", err) + } + for _, d := range dirs { + if fi, err := os.Stat(filepath.Join(UserdataDir(ns), d)); err != nil || !fi.IsDir() { + t.Errorf("skeleton dir %q not created: %v", d, err) + } + } + if _, err := os.Stat(stray); err != nil { + t.Errorf("a pre-existing customer dir was removed — zero-removals violated: %v", err) + } +} diff --git a/controller/internal/appbackup/userdata.go b/controller/internal/appbackup/userdata.go index ecd2668..a7447b9 100644 --- a/controller/internal/appbackup/userdata.go +++ b/controller/internal/appbackup/userdata.go @@ -2,7 +2,10 @@ package appbackup import ( "os" + "path" "path/filepath" + "sort" + "strings" ) // Customer-facing userdata layout + the shared-storage ownership convention (v0.66.0). @@ -28,9 +31,41 @@ func UserdataDir(nsRoot string) string { return filepath.Join(nsRoot, "userdata") } -// UserdataSkeleton is the standard subtree created on every storage path (relative to UserdataDir). +// ImportDirName is the single import (drop-zone) subtree name under a userdata root. +const ImportDirName = "import" + +// ImportDir returns the CANONICAL drop-zone root under a namespace root (R-75). +// +// Unlike every other userdata dir, this one is drive-INDEPENDENT: the caller resolves it against the +// SYSTEM drive's namespace root, never against the app's own HDD_PATH, so a multi-drive box has +// exactly ONE import tree. That is the whole point. Each drop-zone app has exactly one ingest bind, +// so a per-drive import/ would put a folder that LOOKS like a drop-zone on every drive while only +// one of them does anything — and because import paths are `class: excluded`, files stranded in a +// dead one are never backed up either. +// +// It deliberately stays INSIDE the userdata tree, so the 2775/setgid/GID-1000 convention, the +// FileBrowser mount and the ownership rules all apply to it unchanged. +func ImportDir(nsRoot string) string { + return filepath.Join(UserdataDir(nsRoot), ImportDirName) +} + +// UserdataSkeletonCarry is the explicit NON-DERIVED carry-list: every entry the v0.171.0 hardcoded +// skeleton created, retained verbatim and forever. +// +// It exists so the catalog-derived skeleton (R-75) can only ever ADD. That makes the zero-removals +// invariant true BY CONSTRUCTION rather than by review, and it is not hypothetical: +// +// - `documents` is implied by NO catalog app (SPIKE P0(a)) yet exists on both demo boxes and is +// customer-visible — it may hold customer files. Derivation alone would drop it. +// - `import/paperless` and `import/calibre` moved to the canonical system-drive root in R-75, so +// derivation no longer implies them under a data drive either. The pre-existing ones stay put; +// nothing in this arc deletes a directory. +// +// It doubles as the fresh-box floor: on a box whose catalog has not synced yet the derived set is +// empty, and the customer still gets the full standard tree instead of a nearly-empty one. +// // ASCII, no spaces (flows through ${} interpolation, shell, and the rsync merge walk). -func UserdataSkeleton() []string { +func UserdataSkeletonCarry() []string { return []string{ "media", "media/movies", "media/tv", "media/music", "media/audiobooks", "media/books", "media/comics", "media/photos", @@ -41,6 +76,41 @@ func UserdataSkeleton() []string { } } +// BuildUserdataSkeleton merges the catalog-derived dirs with the carry-list into the final, SORTED +// set. Each entry is expanded to its ancestor chain ("media/podcasts" implies "media"), deduped, and +// sorted. +// +// SORTING IS A HARD REQUIREMENT, not tidiness. The FileBrowser config is regenerated from this set +// and fbNeedsRecreate force-recreates the container on ANY byte difference. Go randomises map +// iteration, and the spike measured the naive map-order derivation producing 20 DISTINCT outputs from +// 20 identical runs (SPIKE P6) — which across SyncFileBrowserMounts' ~14 call sites is a fleet-wide +// FileBrowser restart loop. TestSkeletonDeterminism pins this. +func BuildUserdataSkeleton(derived []string) []string { + set := make(map[string]bool, len(derived)+16) + addChain := func(rel string) { + rel = path.Clean(strings.TrimPrefix(filepath.ToSlash(rel), "/")) + if rel == "" || rel == "." || rel == ".." || strings.HasPrefix(rel, "../") { + return // never let a traversal or an empty entry become a directory to create + } + parts := strings.Split(rel, "/") + for i := range parts { + set[strings.Join(parts[:i+1], "/")] = true + } + } + for _, d := range UserdataSkeletonCarry() { + addChain(d) + } + for _, d := range derived { + addChain(d) + } + out := make([]string, 0, len(set)) + for d := range set { // map order is RANDOM — the sort below is what makes this deterministic + out = append(out, d) + } + sort.Strings(out) + return out +} + // EnsureDirOwned creates path (idempotent) and enforces the convention: mode 2775 via an explicit // Chmod incl. setgid (MkdirAll cannot) + group = gid. Setting an arbitrary group needs CAP_CHOWN — // the in-guest controller runs as root, so this succeeds in production. Returns the first hard error. @@ -60,7 +130,11 @@ func EnsureUserdataDir(path string) error { return EnsureDirOwned(path, SharedCo // EnsureUserdataSkeleton creates the full userdata tree under a namespace root with the convention. // It creates ALL dirs even if one errors (so a single chown/chmod hiccup doesn't truncate the tree), // returning the first error seen for the caller to log. -func EnsureUserdataSkeleton(nsRoot string) error { +// +// dirs is the merged, sorted set from BuildUserdataSkeleton. This function only ever CREATES: there +// is no removal path here or anywhere in R-75, so a directory the current catalog no longer implies +// simply stays where it is (Scenario D). +func EnsureUserdataSkeleton(nsRoot string, dirs []string) error { base := UserdataDir(nsRoot) var firstErr error rec := func(e error) { @@ -69,7 +143,7 @@ func EnsureUserdataSkeleton(nsRoot string) error { } } rec(EnsureUserdataDir(base)) - for _, sub := range UserdataSkeleton() { + for _, sub := range dirs { rec(EnsureUserdataDir(filepath.Join(base, sub))) } return firstErr diff --git a/controller/internal/appbackup/userdata_test.go b/controller/internal/appbackup/userdata_test.go index 9ab5898..5e6c11e 100644 --- a/controller/internal/appbackup/userdata_test.go +++ b/controller/internal/appbackup/userdata_test.go @@ -13,10 +13,12 @@ func TestSharedContentGID(t *testing.T) { } } -// TestUserdataSkeleton_List asserts the locked skeleton subdir set. +// TestUserdataSkeleton_List asserts the locked skeleton subdir set. R-75 renamed the hardcoded list +// to UserdataSkeletonCarry (it is now the non-derived carry-list); the asserted set is UNCHANGED, +// which is exactly the zero-removals promise. func TestUserdataSkeleton_List(t *testing.T) { got := map[string]bool{} - for _, s := range UserdataSkeleton() { + for _, s := range UserdataSkeletonCarry() { got[s] = true } for _, want := range []string{ @@ -41,9 +43,10 @@ func TestUserdataDir(t *testing.T) { // is ignored — dirs + setgid still land). Runs cross-platform. func TestEnsureUserdataSkeleton_Structure(t *testing.T) { ns := t.TempDir() - _ = EnsureUserdataSkeleton(ns) // ignore chown error on a non-root CI host + dirs := BuildUserdataSkeleton(nil) // no catalog derived → the carry-list floor + _ = EnsureUserdataSkeleton(ns, dirs) // ignore chown error on a non-root CI host base := UserdataDir(ns) - for _, sub := range append([]string{""}, UserdataSkeleton()...) { + for _, sub := range append([]string{""}, dirs...) { p := filepath.Join(base, sub) if fi, err := os.Stat(p); err != nil || !fi.IsDir() { t.Errorf("skeleton dir missing: %s (%v)", p, err) diff --git a/controller/internal/appexport/estimate_volsize_test.go b/controller/internal/appexport/estimate_volsize_test.go index 727e9c1..0c0d51a 100644 --- a/controller/internal/appexport/estimate_volsize_test.go +++ b/controller/internal/appexport/estimate_volsize_test.go @@ -23,6 +23,7 @@ type hddProvider struct { func (p *hddProvider) GetStackNeedsHDD(string) bool { return true } func (p *hddProvider) GetStackHDDMounts(string) []string { return p.mounts } func (p *hddProvider) GetStackHDDPath(string) string { return p.hddPath } +func (p *hddProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (p *hddProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) { return p.binds, p.hasBinds } diff --git a/controller/internal/appexport/fabplan.go b/controller/internal/appexport/fabplan.go index efa74d8..3f1ac8f 100644 --- a/controller/internal/appexport/fabplan.go +++ b/controller/internal/appexport/fabplan.go @@ -35,7 +35,7 @@ func (e *Exporter) computeFabPlan(req ExportRequest, mounts []string) fabPlan { return fabPlan{} // legacy: byte-identical v0.130.0 capture } hddPath := filepath.Clean(e.provider.GetStackHDDPath(req.StackName)) - fb := appbackup.ComputeFabBuckets(binds, has, hddPath) + fb := appbackup.ComputeFabBuckets(binds, has, hddPath, e.provider.GetImportRoot()) deselect := sliceSet(req.DeselectOptional) optIn := sliceSet(req.OptInExcluded) @@ -117,7 +117,7 @@ func (e *Exporter) fabEstimateSplit(stackName string, est *ExportEstimate, volum return } hddPath := filepath.Clean(e.provider.GetStackHDDPath(stackName)) - fb := appbackup.ComputeFabBuckets(binds, has, hddPath) + fb := appbackup.ComputeFabBuckets(binds, has, hddPath, e.provider.GetImportRoot()) est.HasClassification = true toItems := func(cps []appbackup.CapturePath) ([]FabItem, int64) { diff --git a/controller/internal/appexport/fabplan_test.go b/controller/internal/appexport/fabplan_test.go index 403581f..a8babcf 100644 --- a/controller/internal/appexport/fabplan_test.go +++ b/controller/internal/appexport/fabplan_test.go @@ -23,6 +23,7 @@ type fabProv struct { } func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath } +func (p *fabProv) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts } func (p *fabProv) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) { return p.binds, p.has diff --git a/controller/internal/appexport/provider.go b/controller/internal/appexport/provider.go index 82f6af0..bae561b 100644 --- a/controller/internal/appexport/provider.go +++ b/controller/internal/appexport/provider.go @@ -17,6 +17,9 @@ type ExportStackProvider interface { GetStackHDDMounts(name string) []string // GetStackHDDPath returns the raw HDD_PATH env var from app.yaml. GetStackHDDPath(name string) string + // GetImportRoot returns the CANONICAL drop-zone root (R-75), on the SYSTEM drive. ${IMPORT_PATH} + // binds resolve against THIS, never against GetStackHDDPath. Empty when unresolvable. + GetImportRoot() string // GetStackClassifiedBinds returns the app's backup-classified compose binds + whether it carries a // (valid) backup block (Task 2). Drives the `.fab` class-scoped export plan (Task 4); a legacy app // (false) exports the v0.130.0 full-root capture unchanged. diff --git a/controller/internal/appexport/roundtrip_test.go b/controller/internal/appexport/roundtrip_test.go index 54c8c7d..31590c4 100644 --- a/controller/internal/appexport/roundtrip_test.go +++ b/controller/internal/appexport/roundtrip_test.go @@ -37,6 +37,7 @@ func (p *rtProvider) GetStackComposePath(string) (string, bool) { } func (p *rtProvider) GetStackHDDMounts(string) []string { return nil } func (p *rtProvider) GetStackHDDPath(string) string { return "" } +func (p *rtProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (p *rtProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) { return nil, false } diff --git a/controller/internal/backup/offbox_3a_test.go b/controller/internal/backup/offbox_3a_test.go index a02f4fa..64ffe26 100644 --- a/controller/internal/backup/offbox_3a_test.go +++ b/controller/internal/backup/offbox_3a_test.go @@ -29,6 +29,7 @@ func (p *offbox3aProvider) GetStackComposePath(string) (string, bool) { return " func (p *offbox3aProvider) ListDeployedStacks() []StackSummary { return nil } func (p *offbox3aProvider) GetStackHDDMounts(string) []string { return nil } func (p *offbox3aProvider) GetStackHDDPath(n string) string { return p.hdd[n] } +func (p *offbox3aProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (p *offbox3aProvider) GetDockerVolumes(string) []string { return nil } func (p *offbox3aProvider) StopStack(string) error { return nil } func (p *offbox3aProvider) StartStack(string) error { return nil } diff --git a/controller/internal/backup/offbox_capture.go b/controller/internal/backup/offbox_capture.go index 01f4a9b..ce992b9 100644 --- a/controller/internal/backup/offbox_capture.go +++ b/controller/internal/backup/offbox_capture.go @@ -46,7 +46,7 @@ func (m *Manager) offboxCaptureSet(stack string) (extra []string, warns []string return nil, []string{fmt.Sprintf("Figyelmeztetés: a(z) %s nincs telepítve — csak a mentési egység került a távoli mentésbe.", stack)} } nsRoot := m.namespaceRoot(hdd) - cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, nsRoot) + cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierOffsite, nsRoot, m.stackProvider.GetImportRoot()) var gaps []string // Structurally-refused MANDATORY paths (traversal / bare drive-root / reserved backups/ zone) are diff --git a/controller/internal/backup/recovery_unit_test.go b/controller/internal/backup/recovery_unit_test.go index 62ab30c..d1a8084 100644 --- a/controller/internal/backup/recovery_unit_test.go +++ b/controller/internal/backup/recovery_unit_test.go @@ -37,6 +37,7 @@ func (f *fakeRecoveryProvider) GetStackComposePath(string) (string, bool) { func (f *fakeRecoveryProvider) ListDeployedStacks() []StackSummary { return nil } func (f *fakeRecoveryProvider) GetStackHDDMounts(string) []string { return nil } func (f *fakeRecoveryProvider) GetStackHDDPath(string) string { return f.hdd } +func (f *fakeRecoveryProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (f *fakeRecoveryProvider) GetDockerVolumes(string) []string { return nil } func (f *fakeRecoveryProvider) StopStack(string) error { f.stopped = true diff --git a/controller/internal/backup/tier2_capture.go b/controller/internal/backup/tier2_capture.go index b9d0798..95dd4a5 100644 --- a/controller/internal/backup/tier2_capture.go +++ b/controller/internal/backup/tier2_capture.go @@ -43,7 +43,7 @@ func (m *Manager) tier2CaptureSet(stack, nsRoot string) (legs []tier2Leg, warns // Classified path. if m.stackProvider != nil { if binds, has := m.stackProvider.GetStackClassifiedBinds(stack); has { - cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, nsRoot) + cs := appbackup.ComputeCaptureSet(binds, has, appbackup.TierSecondary, nsRoot, m.stackProvider.GetImportRoot()) var gaps []string for _, sk := range cs.Skipped { if sk.Class == appbackup.ClassMandatory { diff --git a/controller/internal/backup/tier2_restore_test.go b/controller/internal/backup/tier2_restore_test.go index ff76d32..0dea9d5 100644 --- a/controller/internal/backup/tier2_restore_test.go +++ b/controller/internal/backup/tier2_restore_test.go @@ -27,6 +27,7 @@ func (f *t2rFakeProvider) GetStackComposePath(string) (string, bool) { return "" func (f *t2rFakeProvider) ListDeployedStacks() []StackSummary { return nil } func (f *t2rFakeProvider) GetStackHDDMounts(string) []string { return f.mounts } func (f *t2rFakeProvider) GetStackHDDPath(string) string { return f.hdd } +func (f *t2rFakeProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (f *t2rFakeProvider) GetDockerVolumes(string) []string { return nil } func (f *t2rFakeProvider) StopStack(name string) error { f.stopped = append(f.stopped, name) diff --git a/controller/internal/backup/tier2_v2_test.go b/controller/internal/backup/tier2_v2_test.go index cfcd5c9..20ec8cb 100644 --- a/controller/internal/backup/tier2_v2_test.go +++ b/controller/internal/backup/tier2_v2_test.go @@ -25,6 +25,7 @@ func (p *t2v2Provider) GetStackComposePath(string) (string, bool) { return "", f func (p *t2v2Provider) ListDeployedStacks() []StackSummary { return nil } func (p *t2v2Provider) GetStackHDDMounts(n string) []string { return p.mounts[n] } func (p *t2v2Provider) GetStackHDDPath(string) string { return p.hdd } +func (p *t2v2Provider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (p *t2v2Provider) GetDockerVolumes(string) []string { return nil } func (p *t2v2Provider) StopStack(string) error { return nil } func (p *t2v2Provider) StartStack(string) error { return nil } diff --git a/controller/internal/backup/volume_dumps_test.go b/controller/internal/backup/volume_dumps_test.go index f3c4ccf..22e4329 100644 --- a/controller/internal/backup/volume_dumps_test.go +++ b/controller/internal/backup/volume_dumps_test.go @@ -25,6 +25,7 @@ func (f *volDumpFakeProvider) GetStackComposePath(string) (string, bool) { retur func (f *volDumpFakeProvider) ListDeployedStacks() []StackSummary { return f.stacks } func (f *volDumpFakeProvider) GetStackHDDMounts(string) []string { return nil } func (f *volDumpFakeProvider) GetStackHDDPath(name string) string { return f.hdd[name] } +func (f *volDumpFakeProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (f *volDumpFakeProvider) GetDockerVolumes(name string) []string { return f.volumes[name] } func (f *volDumpFakeProvider) StopStack(name string) error { f.stopped = append(f.stopped, name) diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index d29c9ac..bcbe8ce 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -25,6 +25,15 @@ const ( TraefikImage = "traefik:v3.6.7" CloudflaredImage = "cloudflare/cloudflared:2026.6.0" FileBrowserImage = "gtstef/filebrowser:1.3.3-stable" + // FileBrowserImportMount is the in-container mount point NAME for the canonical drop-zone + // (R-75): the bind lands at /srv/. ASCII and space-free on purpose — it appears in a + // container path, in the generated compose, and (percent-encoded) in the deep-link URL. + FileBrowserImportMount = "beolvasas" + // FileBrowserImportLabel is the Hungarian SIDEBAR name of that source. It is the display name and + // it IS the URL identity: FileBrowser Quantum keys sources by name (SPIKE P1) and the deep-link + // template is /files/{encodeURIComponent(name)}/... (SPIKE P2). Accents round-trip correctly — + // the spike verified an accented, spaced and ampersand'd source name end to end. + FileBrowserImportLabel = "Beolvasás" // SambaImage is our own pinned LAN-sharing image (R-7 slice 1). Built by // controller/scripts/build-samba-image.sh from controller/infra-images/samba/. NEVER :latest. SambaImage = "gitea.dooplex.hu/admin/felhom-samba:1.1.0" @@ -256,12 +265,20 @@ http: // RenderFileBrowserConfig returns a FileBrowser Quantum config.yaml with one source per registered // storage path (each a named sidebar entry). Empty paths → a single default /srv source. Ported // verbatim from internal/web/handlers.go. -func RenderFileBrowserConfig(paths []settings.StoragePath) string { +func RenderFileBrowserConfig(paths []settings.StoragePath, importSource bool) string { var sources string - if len(paths) == 0 { + // The canonical drop-zone (R-75) is FIRST and is NOT a registered storage path — it is a separate + // bind of /userdata/import. Separate rather than nested inside a drive source: + // the spike proved a nested source works but gets indexed TWICE (once as its own root, once as a + // child of the parent drive), which buys nothing over a separate bind. + if importSource { + sources += fmt.Sprintf(" - path: %q\n name: %q\n config:\n defaultEnabled: true\n", + "/srv/"+FileBrowserImportMount, FileBrowserImportLabel) + } + if len(paths) == 0 && !importSource { sources = ` - path: "/srv" ` - } else { + } else if len(paths) > 0 { for _, sp := range paths { mountName := filepath.Base(sp.Path) label := sp.Label diff --git a/controller/internal/infra/infra_test.go b/controller/internal/infra/infra_test.go index 1eb5edf..547246f 100644 --- a/controller/internal/infra/infra_test.go +++ b/controller/internal/infra/infra_test.go @@ -46,7 +46,7 @@ func allRendered(t *testing.T) []string { } out = append(out, RenderFileBrowserCompose("example.com", nil)) out = append(out, RenderFileBrowserCompose("example.com", []string{" - /mnt/hdd_1:/srv/hdd_1"})) - out = append(out, RenderFileBrowserConfig(nil)) + out = append(out, RenderFileBrowserConfig(nil, false)) out = append(out, RenderServersTransports()) return out } @@ -224,13 +224,13 @@ func TestFileBrowserRender(t *testing.T) { } // Default config (no storage paths) → a single /srv source. - def := RenderFileBrowserConfig(nil) + def := RenderFileBrowserConfig(nil, false) if !strings.Contains(def, `- path: "/srv"`) { t.Errorf("empty config must default to a /srv source: %q", def) } // With paths → a named per-drive source. - withPaths := RenderFileBrowserConfig([]settings.StoragePath{{Path: "/mnt/hdd_1", Label: "Media"}}) + withPaths := RenderFileBrowserConfig([]settings.StoragePath{{Path: "/mnt/hdd_1", Label: "Media"}}, false) if !strings.Contains(withPaths, `- path: "/srv/hdd_1"`) || !strings.Contains(withPaths, `name: "Media"`) { t.Errorf("storage path not wired into filebrowser config: %q", withPaths) } diff --git a/controller/internal/settings/smb.go b/controller/internal/settings/smb.go index 62bb2ff..7c041ee 100644 --- a/controller/internal/settings/smb.go +++ b/controller/internal/settings/smb.go @@ -30,8 +30,19 @@ type SMBShare struct { ReadOnly bool `json:"read_only,omitempty"` // smb.conf `read only = yes` + a :ro compose bind Offsite bool `json:"offsite"` // [R4] true (default) → backup class mandatory; false → optional (tier-2 only) CreatedAt string `json:"created_at"` // RFC3339 + // System marks a controller-OWNED share the customer may not delete (R-75). Today that is the + // canonical drop-zone („beolvasas"), which is auto-created whenever sharing is enabled and whose + // absence would silently break the documented „drop a file in Beolvasás over the network" flow. + // Its Path is a controller-generated constant derived from config — never customer input — which + // is why it does NOT go through sharingResolvePath (that guard exists to validate the paths a + // CUSTOMER picks, a different trust class). + System bool `json:"system,omitempty"` } +// SystemImportShareName is the fixed name of the canonical drop-zone share (R-75). ASCII and +// nbNameRe-safe on purpose: it is a NetBIOS share name and appears in \\SERVER\. +const SystemImportShareName = "beolvasas" + // nbNameRe matches a NetBIOS-safe name: 1–15 chars, letters/digits/hyphen/underscore, not starting // or ending with a hyphen. Deliberately stricter than SMB share-name rules (slice 1 keeps the flat // name and the share name in the same safe space; slice 2 may relax share names). @@ -172,9 +183,19 @@ func (s *Settings) AddSMBShare(share SMBShare) error { } // RemoveSMBShare deletes a share by name (case-insensitive). Config-only: never touches the folder. +// +// A System share is REFUSED here, at the store layer, so every caller inherits the rule — the web +// handler has its own check too, and that duplication is deliberate: a handler test that POSTs +// directly proves nothing about UI reachability, and a hidden button proves nothing about +// enforcement (the v0.70.1 lesson). They are separate concerns. func (s *Settings) RemoveSMBShare(name string) error { s.mu.Lock() defer s.mu.Unlock() + for _, ex := range s.SMBShares { + if strings.EqualFold(ex.Name, name) && ex.System { + return fmt.Errorf("a(z) „%s” megosztás a rendszer része, nem törölhető", ex.Name) + } + } var kept []SMBShare found := false for _, ex := range s.SMBShares { diff --git a/controller/internal/stacks/captureset_wiring_test.go b/controller/internal/stacks/captureset_wiring_test.go index d30aa1b..86a9bcc 100644 --- a/controller/internal/stacks/captureset_wiring_test.go +++ b/controller/internal/stacks/captureset_wiring_test.go @@ -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) } diff --git a/controller/internal/stacks/classify_binds.go b/controller/internal/stacks/classify_binds.go index fb84579..fb8a66d 100644 --- a/controller/internal/stacks/classify_binds.go +++ b/controller/internal/stacks/classify_binds.go @@ -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 diff --git a/controller/internal/stacks/datapaths.go b/controller/internal/stacks/datapaths.go new file mode 100644 index 0000000..0c167b1 --- /dev/null +++ b/controller/internal/stacks/datapaths.go @@ -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 +} diff --git a/controller/internal/stacks/datapaths_test.go b/controller/internal/stacks/datapaths_test.go new file mode 100644 index 0000000..6ba7964 --- /dev/null +++ b/controller/internal/stacks/datapaths_test.go @@ -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") + } +} diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 5afd3ee..ea8655e 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -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 diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 35db2d5..e38fd59 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -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=/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=/userdata — per-app, on the app's OWN drive (when hdd is non-empty) +// IMPORT_PATH= — 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 } diff --git a/controller/internal/stacks/import_root_classify_test.go b/controller/internal/stacks/import_root_classify_test.go new file mode 100644 index 0000000..05319d1 --- /dev/null +++ b/controller/internal/stacks/import_root_classify_test.go @@ -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/ 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 /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") + } +} diff --git a/controller/internal/stacks/infra.go b/controller/internal/stacks/infra.go index 8129955..cfa43a8 100644 --- a/controller/internal/stacks/infra.go +++ b/controller/internal/stacks/infra.go @@ -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) } diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index f6bed89..522c6b5 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -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): /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 = /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 diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index ebb3cb1..257a635 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -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 + } } } diff --git a/controller/internal/stacks/migrate.go b/controller/internal/stacks/migrate.go index bd1499e..39ca59a 100644 --- a/controller/internal/stacks/migrate.go +++ b/controller/internal/stacks/migrate.go @@ -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 + // /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() diff --git a/controller/internal/stacks/samba_classify_test.go b/controller/internal/stacks/samba_classify_test.go index 7748fae..c5b5ac6 100644 --- a/controller/internal/stacks/samba_classify_test.go +++ b/controller/internal/stacks/samba_classify_test.go @@ -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) diff --git a/controller/internal/stacks/skeleton_derive.go b/controller/internal/stacks/skeleton_derive.go new file mode 100644 index 0000000..21fb56c --- /dev/null +++ b/controller/internal/stacks/skeleton_derive.go @@ -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) +} diff --git a/controller/internal/stacks/userdata_belt_test.go b/controller/internal/stacks/userdata_belt_test.go index 0bf7c55..5fb2e06 100644 --- a/controller/internal/stacks/userdata_belt_test.go +++ b/controller/internal/stacks/userdata_belt_test.go @@ -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=/userdata when HDD_PATH is set, and +// TestWithPathVars: the shared injector adds USERDATA_PATH=/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) + } + } +} diff --git a/controller/internal/web/async_restore_test.go b/controller/internal/web/async_restore_test.go index 9c15288..cf5dfbd 100644 --- a/controller/internal/web/async_restore_test.go +++ b/controller/internal/web/async_restore_test.go @@ -30,6 +30,7 @@ func (p *blockProvider) GetStackComposePath(string) (string, bool) { return "", func (p *blockProvider) ListDeployedStacks() []backup.StackSummary { return nil } func (p *blockProvider) GetStackHDDMounts(string) []string { return nil } func (p *blockProvider) GetStackHDDPath(string) string { return p.hdd } +func (p *blockProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (p *blockProvider) GetDockerVolumes(string) []string { return nil } func (p *blockProvider) StopStack(string) error { atomic.AddInt32(&p.stops, 1) diff --git a/controller/internal/web/datapath_card.go b/controller/internal/web/datapath_card.go new file mode 100644 index 0000000..ed6f451 --- /dev/null +++ b/controller/internal/web/datapath_card.go @@ -0,0 +1,139 @@ +package web + +import ( + "fmt" + "path/filepath" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// „Hova tegyem a fájlokat?" — the app-page folder card (R-75). +// +// Rendered only for DEPLOYED apps that declare data_paths (Fork-2: all catalog apps get the +// skeleton, but only deployed apps get a UI affordance — an empty folder for an app nobody installed +// is the thing that would actually confuse someone). + +// DataPathCard is one folder row on the app page. +type DataPathCard struct { + Label string // the catalog's Hungarian label + Link string // FileBrowser deep link + Consequence string // class-DRIVEN copy (never hand-written per app) + IsImport bool // drives the free-space line + FreeSpace string // system-drive headroom, import rows only ("" when unreadable) +} + +// consequenceFor maps a folder's DERIVED BACKUP CLASS to the sentence the customer reads (Fork-4). +// +// It is driven by the class, not by the role and not by a per-app string, so the promise the UI makes +// can never drift from what the backup engines actually do. `excluded` means the tier filter drops it +// at EVERY tier — so a drop-zone must say, in the customer's own language, that the folder is +// temporary and unbacked. Saying anything softer would be a false promise about their files. +func consequenceFor(class appbackup.BindClass, role stacks.DataPathRole) string { + switch class { + case appbackup.ClassExcluded: + if role == stacks.RoleImport { + return "Ide másold a feldolgozandó fájlokat. Az alkalmazás beolvassa, majd törli innen — " + + "ez a mappa átmeneti, és nem készül róla biztonsági mentés." + } + return "Ez a mappa átmeneti, és nem készül róla biztonsági mentés." + case appbackup.ClassMandatory, appbackup.ClassOptional: + return "Itt tárolódnak a fájljaid. Biztonsági mentés készül róla." + default: + // No classification (legacy app, or a bind with no backup block). Say nothing rather than + // guess — an unverified backup promise is worse than no sentence at all. + return "" + } +} + +// buildDataPathCards turns an app's validated data_paths into rendered rows. +// +// classOf resolves a (root, relpath) to its backup class; missing ⇒ empty class ⇒ no consequence +// line. A row whose deep link cannot be built (no domain) is dropped rather than rendered dead. +func (s *Server) buildDataPathCards(st *stacks.Stack) []DataPathCard { + if st == nil || !st.Deployed || len(st.Meta.DataPaths) == 0 { + return nil + } + domain := s.cfg.Customer.Domain + if domain == "" { + return nil + } + + classOf := map[string]appbackup.BindClass{} + if binds, has := s.stackMgr.ClassifiedBinds(st.Name); has { + for _, b := range binds { + classOf[string(b.Root)+"\x00"+b.RelPath] = b.Class + } + } + + var freeSpace string + if s.stackMgr != nil { + if root := s.stackMgr.GetImportRoot(); root != "" { + if du := system.GetDiskUsage(root); du != nil { + freeSpace = fmt.Sprintf("%.1f GB szabad", du.AvailGB) + } + } + } + + cards := make([]DataPathCard, 0, len(st.Meta.DataPaths)) + for _, dp := range st.Meta.DataPaths { + var link string + switch dp.Root { + case appbackup.RootImport: + link = importFolderLink(domain, dp.Path) + case appbackup.RootUserdata: + // A userdata folder lives on the app's OWN drive, so its FileBrowser source is that + // drive's sidebar entry. Resolve it from the app's HDD_PATH; skip the row if we cannot. + src := s.fbSourceNameForApp(st.Name) + if src == "" { + continue + } + link = fileBrowserLink(domain, src, dp.Path) + default: + // hdd: app-internal (appdata/) — NOT customer-browsable, FileBrowser does not mount it. + // Surfacing a link here would 404. Skipped deliberately; the catalog should not annotate + // an hdd path with a customer-facing role. + continue + } + isImport := dp.Role == stacks.RoleImport + card := DataPathCard{ + Label: dp.Label, + Link: link, + Consequence: consequenceFor(classOf[string(dp.Root)+"\x00"+dp.Path], dp.Role), + IsImport: isImport, + } + if isImport { + // The system SSD filling is a different severity from a data drive filling — it can take + // the whole guest down — and the customer has no other signal that the drop-zone is not + // bottomless. + card.FreeSpace = freeSpace + } + cards = append(cards, card) + } + return cards +} + +// fbSourceNameForApp returns the FileBrowser sidebar source name for the drive an app is deployed +// on: the storage path's label when it has one, else the mount basename — exactly what +// RenderFileBrowserConfig emits, so the deep link and the sidebar can never disagree. +func (s *Server) fbSourceNameForApp(stackName string) string { + appCfg := s.stackMgr.LoadAppConfigByName(stackName) + if appCfg == nil { + return "" + } + hdd := appCfg.Env["HDD_PATH"] + if hdd == "" { + return "" + } + for _, sp := range s.settings.GetStoragePaths() { + if sp.Path != hdd || sp.Decommissioned { + continue + } + if sp.Label != "" { + return sp.Label + } + return filepath.Base(sp.Path) + } + return "" +} diff --git a/controller/internal/web/datapath_card_test.go b/controller/internal/web/datapath_card_test.go new file mode 100644 index 0000000..c8880d8 --- /dev/null +++ b/controller/internal/web/datapath_card_test.go @@ -0,0 +1,117 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// appInfoWithCards overlays the R-75 folder card onto the shared app_info fixture. It reuses +// appInfoData (lifecycle_test.go) deliberately — that helper passes a *Metadata for a reason +// documented there (value vs pointer receivers at render time); do not fork it. +func appInfoWithCards(st stacks.Stack, cards []DataPathCard) map[string]interface{} { + d := appInfoData(st) + if len(cards) > 0 { + d["DataPathCards"] = cards + } + return d +} + +// SEAM-WIRING RULE: a conditional affordance ships with a render test PER BRANCH of its gate. +// Branch 1 — cards present: the block renders, with the label, the deep link and the copy. +func TestAppInfo_DataPathCardRenders(t *testing.T) { + st := stacks.Stack{Name: "paperless-ngx", Deployed: true, State: stacks.StateRunning, + Meta: stacks.Metadata{Slug: "paperless-ngx", DisplayName: "Paperless-ngx"}} + cards := []DataPathCard{{ + Label: "Beolvasandó dokumentumok", + Link: importFolderLink("demo-felhom.eu", "paperless"), + Consequence: consequenceFor(appbackup.ClassExcluded, stacks.RoleImport), + IsImport: true, + FreeSpace: "44.8 GB szabad", + }} + html := renderBackupPage(t, "app_info", appInfoWithCards(st, cards)) + + if !strings.Contains(html, "Hova tegyem a fájlokat?") { + t.Error("the folder card heading must render") + } + if !strings.Contains(html, "Beolvasandó dokumentumok") { + t.Error("the catalog label must render") + } + // The deep link must reach the page INTACT — this is the assertion the brief asks for, and it is + // what would have caught the v0.150.0 class of bug (a key rendered where another belonged). + if !strings.Contains(html, "https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless") { + t.Errorf("the FileBrowser deep link must render intact; body:\n%s", excerpt(html, "datapath")) + } + if !strings.Contains(html, "nem készül róla biztonsági mentés") { + t.Error("an excluded drop-zone must say it is unbacked") + } + if !strings.Contains(html, "44.8 GB szabad") { + t.Error("the system-drive free space must render on an import row") + } + // The copy must not promise a single click — a cold deep link goes through the FileBrowser login. + if !strings.Contains(html, "be kell jelentkezned") { + t.Error("the card must warn that a FileBrowser sign-in may be needed") + } +} + +// Branch 2 — no cards: nothing renders, and in particular no empty heading. +func TestAppInfo_NoDataPathCardsRendersNothing(t *testing.T) { + st := stacks.Stack{Name: "docmost", Deployed: true, State: stacks.StateRunning, + Meta: stacks.Metadata{Slug: "docmost", DisplayName: "Docmost"}} + html := renderBackupPage(t, "app_info", appInfoWithCards(st, nil)) + if strings.Contains(html, "Hova tegyem a fájlokat?") { + t.Error("an app with no data_paths must not render the folder card") + } + if strings.Contains(html, "datapath-row") { + t.Error("no folder rows must render") + } +} + +// Fork-4: the consequence line is CLASS-driven, so the UI can never promise a backup the engines do +// not make. `excluded` is dropped at every tier — it must say so. +func TestConsequenceIsClassDriven(t *testing.T) { + imp := consequenceFor(appbackup.ClassExcluded, stacks.RoleImport) + if !strings.Contains(imp, "törli innen") || !strings.Contains(imp, "nem készül róla biztonsági mentés") { + t.Errorf("an excluded import folder must say it is temporary AND unbacked: %q", imp) + } + for _, cls := range []appbackup.BindClass{appbackup.ClassMandatory, appbackup.ClassOptional} { + lib := consequenceFor(cls, stacks.RoleLibrary) + if !strings.Contains(lib, "Biztonsági mentés készül") { + t.Errorf("class %q must promise a backup: %q", cls, lib) + } + if strings.Contains(lib, "nem készül") { + t.Errorf("class %q must NOT say unbacked: %q", cls, lib) + } + } + // An unclassified bind says NOTHING rather than guessing — an unverified backup promise about a + // customer's files is worse than no sentence. + if got := consequenceFor("", stacks.RoleLibrary); got != "" { + t.Errorf("an unclassified path must produce no promise, got %q", got) + } +} + +// A card is only built for a DEPLOYED app (Fork-2: deployed-only in the UI). +func TestBuildDataPathCards_UndeployedYieldsNothing(t *testing.T) { + s := testServer(t) + st := &stacks.Stack{Name: "paperless-ngx", Deployed: false, + Meta: stacks.Metadata{Slug: "paperless-ngx", DataPaths: []stacks.DataPath{ + {Path: "paperless", Root: appbackup.RootImport, Role: stacks.RoleImport, Label: "x"}, + }}} + if got := s.buildDataPathCards(st); got != nil { + t.Errorf("an undeployed app must get no folder card, got %v", got) + } +} + +func excerpt(html, needle string) string { + i := strings.Index(html, needle) + if i < 0 { + return "(needle not found)" + } + end := i + 400 + if end > len(html) { + end = len(html) + } + return html[i:end] +} diff --git a/controller/internal/web/fab_export_test.go b/controller/internal/web/fab_export_test.go index 9657693..23735ed 100644 --- a/controller/internal/web/fab_export_test.go +++ b/controller/internal/web/fab_export_test.go @@ -33,6 +33,7 @@ func (p *fabWebProvider) GetStackHDDMounts(string) []string { return []string{appbackup.UserdataDir(p.hddPath)} } func (p *fabWebProvider) GetStackHDDPath(string) string { return p.hddPath } +func (p *fabWebProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (p *fabWebProvider) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) { return p.binds, true } diff --git a/controller/internal/web/filebrowser_link.go b/controller/internal/web/filebrowser_link.go new file mode 100644 index 0000000..b35dcb7 --- /dev/null +++ b/controller/internal/web/filebrowser_link.go @@ -0,0 +1,64 @@ +package web + +import ( + "net/url" + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" +) + +// FileBrowser Quantum deep links (R-75). +// +// The template is taken VERBATIM from the shipped frontend router (SPIKE P2, read out of the +// bundle), not guessed: +// +// function Ms(n,t,…){ … let a=q2(t), s=`/files/${encodeURIComponent(n)}${a}` … } +// q2 = … .map(r=>encodeURIComponent(r)).join("/") // per-SEGMENT encoding +// function H2(n){ t=i.split("/")[2]; … } // source is the 3rd path segment +// +// So a link is constructible server-side from (sourceName, relPath) alone — no internal id, no +// index, no client-side state. +// +// A cold link (no FileBrowser session) is NOT lost: the router guard redirects to +// /login?redirect= and the login handler navigates back to it. The customer may still have +// to sign in, which is why the UI copy must not promise one click. + +// fbSourceRoot is the FileBrowser origin for a customer domain. FileBrowser is published at +// files. by the base-infra traefik labels. +func fbSourceRoot(domain string) string { + return "https://files." + domain +} + +// fileBrowserLink builds a deep link into a named FileBrowser source at relPath. +// +// ENCODING TRAP, measured in SPIKE P2 — use url.PathEscape, NEVER url.QueryEscape: +// +// "Média & könyvtár" PathEscape=M%C3%A9dia%20&%20k%C3%B6nyvt%C3%A1r QueryEscape=M%C3%A9dia+%26+k%C3%B6nyvt%C3%A1r +// "a+b" PathEscape=a+b QueryEscape=a%2Bb +// +// QueryEscape encodes a space as "+", which inside a PATH segment means a literal plus and breaks +// the link. PathEscape leaves "&" unescaped, which is correct here: "&" is a legal path sub-delim, +// and html/template escapes it to "&" in the href attribute, which the browser decodes back to +// "&". The two escapings compose — so do NOT pre-escape for HTML here. +// +// Source names cannot contain "/" (they come from filepath.Base or the ASCII import constant), which +// is what keeps the router's split("/")[2] round-trip intact. +func fileBrowserLink(domain, sourceName, relPath string) string { + var b strings.Builder + b.WriteString(fbSourceRoot(domain)) + b.WriteString("/files/") + b.WriteString(url.PathEscape(sourceName)) + for _, seg := range strings.Split(strings.Trim(relPath, "/"), "/") { + if seg == "" { + continue + } + b.WriteString("/") + b.WriteString(url.PathEscape(seg)) + } + return b.String() +} + +// importFolderLink is the deep link to a drop-zone app's folder inside the canonical import source. +func importFolderLink(domain, appDir string) string { + return fileBrowserLink(domain, infra.FileBrowserImportLabel, appDir) +} diff --git a/controller/internal/web/filebrowser_link_test.go b/controller/internal/web/filebrowser_link_test.go new file mode 100644 index 0000000..9500ea4 --- /dev/null +++ b/controller/internal/web/filebrowser_link_test.go @@ -0,0 +1,95 @@ +package web + +import ( + "html/template" + "net/url" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/infra" +) + +// The deep-link template, pinned against the shipped FileBrowser Quantum router (SPIKE P2). +func TestFileBrowserLink_Template(t *testing.T) { + const dom = "demo-felhom.eu" + for _, tc := range []struct{ name, source, rel, want string }{ + {"ascii source, one segment", "hdd_1", "media/movies", + "https://files.demo-felhom.eu/files/hdd_1/media/movies"}, + {"accented source (the import label)", infra.FileBrowserImportLabel, "paperless", + "https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless"}, + {"source root, empty relpath", infra.FileBrowserImportLabel, "", + "https://files.demo-felhom.eu/files/Beolvas%C3%A1s"}, + {"leading/trailing slashes are ignored", "hdd_1", "/media/books/", + "https://files.demo-felhom.eu/files/hdd_1/media/books"}, + {"accented path segment", "hdd_1", "media/könyvek", + "https://files.demo-felhom.eu/files/hdd_1/media/k%C3%B6nyvek"}, + } { + if got := fileBrowserLink(dom, tc.source, tc.rel); got != tc.want { + t.Errorf("%s:\n got %q\n want %q", tc.name, got, tc.want) + } + } +} + +// THE trap, measured in SPIKE P2. QueryEscape turns a space into "+", which in a path segment is a +// literal plus and lands the customer on a folder that does not exist. This test fails if anyone +// swaps the escaper. +func TestFileBrowserLink_UsesPathEscapeNotQueryEscape(t *testing.T) { + const spaced = "Média & könyvtár" + got := fileBrowserLink("x.eu", spaced, "a b") + + if strings.Contains(got, "+") { + t.Errorf("link contains '+' — QueryEscape was used somewhere; a '+' in a path segment is a literal plus, not a space: %q", got) + } + if want := url.PathEscape(spaced); !strings.Contains(got, want) { + t.Errorf("source not PathEscape'd: got %q, want it to contain %q", got, want) + } + // Guard the exact divergence the spike measured, so the two escapers can never be confused here. + if url.PathEscape(spaced) == url.QueryEscape(spaced) { + t.Fatal("fixture no longer distinguishes the two escapers — pick a name where they differ") + } + if strings.Contains(got, url.QueryEscape(spaced)) { + t.Errorf("link used QueryEscape: %q", got) + } +} + +// The link is embedded in HTML. Percent-encoding and attribute-escaping must COMPOSE: PathEscape +// leaves "&" bare, html/template turns it into "&", and the browser decodes it back to "&". +// Anything that double-encodes (a hand-rolled HTML escape before the template) breaks the path. +func TestFileBrowserLink_ComposesWithHTMLEscaping(t *testing.T) { + link := fileBrowserLink("x.eu", "Média & könyvtár", "docs") + var sb strings.Builder + tmpl := template.Must(template.New("a").Parse(`x`)) + if err := tmpl.Execute(&sb, link); err != nil { + t.Fatal(err) + } + out := sb.String() + if !strings.Contains(out, "&") { + t.Errorf("html/template should escape the bare & in the href: %q", out) + } + // It must NOT have been percent-encoded a second time (%2526 etc. would be double-encoding). + if strings.Contains(out, "%25") { + t.Errorf("double percent-encoding detected — do not pre-escape before the template: %q", out) + } + // And no raw quote/angle escaped into the attribute. + if strings.Contains(out, `href=""`) { + t.Errorf("html/template refused the URL (would render an empty href): %q", out) + } +} + +// importFolderLink targets the CANONICAL source, so a drop-zone link is identical regardless of +// which drive the app itself sits on. +func TestImportFolderLink_IsCanonical(t *testing.T) { + a := importFolderLink("demo-felhom.eu", "paperless") + b := importFolderLink("demo-felhom.eu", "calibre") + for _, l := range []string{a, b} { + if !strings.Contains(l, "/files/"+url.PathEscape(infra.FileBrowserImportLabel)+"/") { + t.Errorf("import link must go through the canonical source: %q", l) + } + if strings.Contains(l, "hdd_1") || strings.Contains(l, "felhom-drives") { + t.Errorf("import link must never name a data drive: %q", l) + } + } + if a == b { + t.Error("different apps must get different folders") + } +} diff --git a/controller/internal/web/filebrowser_network_test.go b/controller/internal/web/filebrowser_network_test.go index 2605231..5908a8d 100644 --- a/controller/internal/web/filebrowser_network_test.go +++ b/controller/internal/web/filebrowser_network_test.go @@ -78,7 +78,7 @@ func TestFileBrowserNetworkShareIncluded(t *testing.T) { t.Errorf("skeleton calls = %v, want exactly [%s] — a skeleton toward the NAS writes Felhom convention dirs onto the customer's own NAS", calls, drive.Path) } // Config has both sources, share named by its display label. - cfg := infra.RenderFileBrowserConfig(cfgPaths) + cfg := infra.RenderFileBrowserConfig(cfgPaths, false) for _, m := range []string{`- path: "/srv/hdd_1"`, `- path: "/srv/Felhom-Share"`, `name: "Felhom Share"`} { if !strings.Contains(cfg, m) { t.Errorf("config missing %q:\n%s", m, cfg) @@ -104,7 +104,7 @@ func TestFileBrowserNetworkStubExcluded(t *testing.T) { if len(mounts) != 1 || strings.Contains(mounts[0], "Felhom-Share") { t.Errorf("stub share leaked into mounts: %v", mounts) } - cfg := infra.RenderFileBrowserConfig(cfgPaths) + cfg := infra.RenderFileBrowserConfig(cfgPaths, false) if strings.Contains(cfg, "Felhom-Share") { t.Errorf("stub share leaked into the source list:\n%s", cfg) } @@ -150,8 +150,8 @@ func TestFileBrowserNetworkRemoval(t *testing.T) { oldCompose := infra.RenderFileBrowserCompose("example.hu", withMounts) newCompose := infra.RenderFileBrowserCompose("example.hu", withoutMounts) - oldCfg := infra.RenderFileBrowserConfig(withCfg) - newCfg := infra.RenderFileBrowserConfig(withoutCfg) + oldCfg := infra.RenderFileBrowserConfig(withCfg, false) + newCfg := infra.RenderFileBrowserConfig(withoutCfg, false) if strings.Contains(newCompose, "Felhom-Share") || strings.Contains(newCfg, "Felhom-Share") { t.Error("removed share left a trace in the renders") diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index b278b33..47cde49 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -636,6 +636,13 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s data["HasAppInfo"] = found.Meta.HasAppInfo() data["EffectiveSubdomain"] = effectiveSubdomain + // „Hova tegyem a fájlokat?" (R-75) — deployed apps that declare data_paths only. Set EXPLICITLY, + // like every other key here: appDetailHandler does not funnel through baseData, and the v0.150.0 + // app_export.html bug (a CSRF token rendered where the domain belonged) came from assuming it did. + if cards := s.buildDataPathCards(found); len(cards) > 0 { + data["DataPathCards"] = cards + } + // Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first // boot). Read it live from the container so the customer doesn't have to dig through logs. Only // for deployed apps that declare an initial_credentials spec; hidden when unreadable. @@ -2217,13 +2224,29 @@ func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) { storageMounts, configPaths := buildFileBrowserPaths(paths, fbPathDeps{ isMount: system.IsMountPoint, classify: s.classifyFSPath, - ensureSkeleton: appbackup.EnsureUserdataSkeleton, + ensureSkeleton: s.ensureUserdataSkeleton, logger: s.logger, }) + // R-75: the canonical drop-zone is an EXTRA bind, outside the registered-storage-path loop above. + // The system drive is deliberately not a registered StoragePath (it would become a customer-visible + // drive, a deploy target and a wipe candidate), so it is mounted here explicitly. Ensure the root + // first — a source whose path does not exist renders a broken sidebar entry. + importSource := false + if s.stackMgr != nil { + if importRoot := s.stackMgr.GetImportRoot(); importRoot != "" { + if err := s.stackMgr.EnsureImportRoot(); err != nil { + s.logger.Printf("[WARN] [web] FileBrowser: could not ensure the import root %s: %v", importRoot, err) + } + storageMounts = append(storageMounts, + fmt.Sprintf(" - %s:/srv/%s", importRoot, infra.FileBrowserImportMount)) + importSource = true + } + } + // Generate and write config.yaml (sources + sidebar entries per drive/share) configPath := stackDir + "/config.yaml" - fbConfig := generateFileBrowserConfig(configPaths) + fbConfig := generateFileBrowserConfig(configPaths, importSource) // Capture the current on-disk content BEFORE any writes, so we can detect whether this sync // actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml @@ -2382,6 +2405,6 @@ func generateFileBrowserCompose(domain string, storageMounts []string) string { // generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per // registered storage path. Delegates to internal/infra (single source of truth). -func generateFileBrowserConfig(paths []settings.StoragePath) string { - return infra.RenderFileBrowserConfig(paths) +func generateFileBrowserConfig(paths []settings.StoragePath, importSource bool) string { + return infra.RenderFileBrowserConfig(paths, importSource) } diff --git a/controller/internal/web/import_share_test.go b/controller/internal/web/import_share_test.go new file mode 100644 index 0000000..863f5b2 --- /dev/null +++ b/controller/internal/web/import_share_test.go @@ -0,0 +1,220 @@ +package web + +import ( + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// R-75 Scenario E — the system share's delete refusal is SERVER-SIDE. +// +// Two independent checks, tested independently on purpose (the v0.70.1 lesson): a handler test that +// POSTs directly proves nothing about UI reachability, and a render gate proves nothing about +// enforcement. Both are required; neither substitutes for the other. + +func serverWithImportShare(t *testing.T) *Server { + t.Helper() + s := testServer(t) + // A real Manager so the handler's post-delete ReconcileSamba has a receiver. Sharing is left + // DISABLED, so reconcileSambaAt early-returns and no docker call is made. + s.cfg.Paths.SystemDataPath = "/mnt/sys_drive" + s.cfg.Paths.StacksDir = t.TempDir() + mgr, err := stacks.NewManager(s.cfg, s.logger) + if err != nil { + t.Fatal(err) + } + mgr.SetMigrationDeps(s.settings, func() bool { return false }) + s.stackMgr = mgr + if err := s.settings.AddSMBShare(settings.SMBShare{ + Name: settings.SystemImportShareName, Path: "/mnt/sys_drive/felhom-data/userdata/import", + System: true, + }); err != nil { + t.Fatal(err) + } + if err := s.settings.AddSMBShare(settings.SMBShare{ + Name: "csalad", Path: "/mnt/felhom-drives/hdd_1/shares/csalad", + }); err != nil { + t.Fatal(err) + } + return s +} + +// Scenario E, enforcement half: POST the delete endpoint directly. The share must survive. +func TestScenarioE_SystemShareDeleteRefusedServerSide(t *testing.T) { + s := serverWithImportShare(t) + + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/sharing/shares/delete", + strings.NewReader(url.Values{"name": {settings.SystemImportShareName}}.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.sharingShareDeleteHandler(rr, req) + + found := false + for _, sh := range s.settings.GetSMBShares() { + if strings.EqualFold(sh.Name, settings.SystemImportShareName) { + found = true + } + } + if !found { + t.Fatal("the system share was DELETED by a direct POST — the refusal is not server-side") + } + + // And a non-system share is still deletable, so this is a targeted refusal and not a broken + // endpoint that happens to refuse everything. + rr2 := httptest.NewRecorder() + req2 := httptest.NewRequest("POST", "/sharing/shares/delete", + strings.NewReader(url.Values{"name": {"csalad"}}.Encode())) + req2.Header.Set("Content-Type", "application/x-www-form-urlencoded") + s.sharingShareDeleteHandler(rr2, req2) + for _, sh := range s.settings.GetSMBShares() { + if sh.Name == "csalad" { + t.Error("an ordinary share must still be deletable") + } + } +} + +// The store layer refuses too, so no future caller can bypass the handler. +func TestScenarioE_StoreLayerRefusesSystemShare(t *testing.T) { + s := serverWithImportShare(t) + if err := s.settings.RemoveSMBShare(settings.SystemImportShareName); err == nil { + t.Error("RemoveSMBShare must refuse a System share") + } + if err := s.settings.RemoveSMBShare("csalad"); err != nil { + t.Errorf("RemoveSMBShare must still delete an ordinary share: %v", err) + } +} + +// Scenario E, reachability half: the template must not render a delete button for a system share — +// and must still render one for an ordinary share. +func TestScenarioE_SharingTemplateOmitsSystemDeleteButton(t *testing.T) { + data := map[string]interface{}{ + "Page": "sharing", "Title": "Hálózati megosztás", + "SMBEnabled": true, + // The SAME type the handler passes — see ShareRow's comment. + "SMBShares": []ShareRow{ + {Name: settings.SystemImportShareName, Path: "/mnt/sys_drive/felhom-data/userdata/import", System: true, Available: true}, + {Name: "csalad", Path: "/mnt/felhom-drives/hdd_1/shares/csalad", Available: true}, + }, + "CSRFField": "", + } + html := renderBackupPage(t, "sharing", data) + + rows := strings.Split(html, "") + var sysRow, normalRow string + for _, row := range rows { + if strings.Contains(row, settings.SystemImportShareName) { + sysRow = row + } + if strings.Contains(row, "csalad") { + normalRow = row + } + } + if sysRow == "" || normalRow == "" { + t.Fatalf("both share rows must render; sys=%v normal=%v", sysRow != "", normalRow != "") + } + if strings.Contains(sysRow, "/sharing/shares/delete") { + t.Error("the system share row must NOT carry a delete form") + } + if !strings.Contains(normalRow, "/sharing/shares/delete") { + t.Error("an ordinary share row must still carry its delete form") + } +} + +// R-75 Scenario F — sharing stays OPT-IN. Deploying a drop-zone app must not put SMB on the LAN. +func TestScenarioF_SharingStaysOptIn(t *testing.T) { + s := testServer(t) + if s.settings.GetSMBSettings().Enabled { + t.Fatal("precondition: sharing must start disabled") + } + // The auto-create is wired to the ENABLE handler only; nothing in the deploy path calls it. + // Assert the state a fresh box is in: no shares at all. + if got := s.settings.GetSMBShares(); len(got) != 0 { + t.Errorf("a fresh box must have no shares before sharing is enabled, got %v", got) + } + if s.settings.GetSMBSettings().Enabled { + t.Error("sharing must not have been switched on") + } +} + +// ensureImportShare is idempotent and correctly shaped. +func TestEnsureImportShare_IdempotentAndCorrect(t *testing.T) { + s := testServer(t) + // A writable stand-in for /mnt/sys_drive so EnsureImportRoot really creates the dir (the test + // user is not root, so the real path is not writable). + s.cfg.Paths.SystemDataPath = t.TempDir() + s.cfg.Paths.StacksDir = t.TempDir() + mgr, err := stacks.NewManager(s.cfg, s.logger) + if err != nil { + t.Fatal(err) + } + s.stackMgr = mgr + + for i := 0; i < 3; i++ { + if err := s.ensureImportShare(); err != nil { + t.Fatalf("call %d: %v", i+1, err) + } + } + shares := s.settings.GetSMBShares() + if len(shares) != 1 { + t.Fatalf("expected exactly 1 share after 3 calls (idempotent), got %d: %v", len(shares), shares) + } + sh := shares[0] + if sh.Name != settings.SystemImportShareName { + t.Errorf("share name = %q, want %q", sh.Name, settings.SystemImportShareName) + } + if !sh.System { + t.Error("the import share must be marked System") + } + if sh.Offsite { + t.Error("the drop-zone is class `excluded` — Offsite must be false, or the UI would contradict the backup engines") + } + if sh.ReadOnly { + t.Error("a drop-zone the customer copies INTO must be writable") + } + if want := mgr.GetImportRoot(); sh.Path != want { + t.Errorf("share path = %q, want the canonical import root %q", sh.Path, want) + } + // The name must be NetBIOS-safe — it is an SMB share name. + if err := settings.ValidateSMBShareName(sh.Name); err != nil { + t.Errorf("share name is not NetBIOS-safe: %v", err) + } +} + +// The brief's re-assertion: SPIKE P4 proved /userdata/import is shareable against a GENERIC +// registered root. This pins the SYSTEM-root shape specifically, because ProtectedHDDPaths has a +// legacy felhom-data double-nest branch that only fires there. +// +// It documents the actual live shape, which is why ensureImportShare does not route through the +// picker guard: the system drive is NOT a registered storage path on either demo box (verified +// 2026-07-26), so sharingResolvePath — whose job is to validate CUSTOMER-supplied paths — refuses it. +// A controller-generated constant is a different trust class. +func TestImportRoot_NotReachableViaTheCustomerPicker(t *testing.T) { + s := testServer(t) + root := t.TempDir() // stands in for the system drive; deliberately NOT registered + importRoot := filepath.Join(root, "felhom-data", "userdata", "import") + if err := os.MkdirAll(importRoot, 0o755); err != nil { + t.Fatal(err) + } + // A registered data drive exists, so this is not "the registry is empty" trivially refusing. + dataDrive := t.TempDir() + if err := os.MkdirAll(filepath.Join(dataDrive, "userdata", "import"), 0o755); err != nil { + t.Fatal(err) + } + if err := s.settings.AddStoragePath(settings.StoragePath{Path: dataDrive, Label: "hdd_1", IsDefault: true}); err != nil { + t.Fatal(err) + } + + if _, err := s.sharingResolvePath(importRoot); err == nil { + t.Error("the customer picker must NOT accept the unregistered system-drive import root") + } + // Control: the data drive's own userdata subtree IS pickable, so the guard is not refusing all. + if _, err := s.sharingResolvePath(filepath.Join(dataDrive, "userdata", "import")); err != nil { + t.Errorf("a registered drive's userdata/import must stay shareable: %v", err) + } +} diff --git a/controller/internal/web/sharing_handlers.go b/controller/internal/web/sharing_handlers.go index d36b0cd..1f44a6c 100644 --- a/controller/internal/web/sharing_handlers.go +++ b/controller/internal/web/sharing_handlers.go @@ -115,6 +115,19 @@ func (s *Server) sharingResolveStorageRoot(raw string) (string, error) { return "", errNotShareable } +// ShareRow is one row of the shares table. It is a PACKAGE-LEVEL type, not a function-local struct, +// so the render test constructs the exact shape the handler passes: this template reads .System and +// .Available, and a field present in one and missing from the other is a render-time 500 that no +// handler test would catch (the template-gate class this project has hit four times). +type ShareRow struct { + Name string + Path string + ReadOnly bool + Offsite bool + System bool // controller-owned (R-75): no delete button, and the handler refuses it anyway + Available bool +} + // sharingPageData assembles the „Megosztás" page state. func (s *Server) sharingPageData() map[string]interface{} { data := s.settingsBaseData("sharing", "Hálózati megosztás") @@ -134,18 +147,12 @@ func (s *Server) sharingPageData() map[string]interface{} { data["SMBDirectAddress"] = s.sambaLANAddress() } - type shareRow struct { - Name string - Path string - ReadOnly bool - Offsite bool - Available bool - } - var rows []shareRow + var rows []ShareRow for _, sh := range s.settings.GetSMBShares() { fi, err := os.Stat(sh.Path) - rows = append(rows, shareRow{ + rows = append(rows, ShareRow{ Name: sh.Name, Path: sh.Path, ReadOnly: sh.ReadOnly, Offsite: sh.Offsite, + System: sh.System, Available: err == nil && fi.IsDir(), }) } @@ -272,6 +279,14 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) { sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.") return } + // R-75: the canonical drop-zone share exists whenever sharing is ON — and NEVER before. Enabling + // sharing is the customer's decision (it puts SMB on the household LAN and demands a household + // password); deploying a drop-zone app must not trigger it. "Mandatory" here means "always present + // once sharing is on", not "turns sharing on". + if err := s.ensureImportShare(); err != nil { + s.logger.Printf("[WARN] [sharing] could not ensure the import share: %v", err) + } + // v0.147.0 (4b): the bring-up runs DETACHED and the page polls it. Synchronously it was a form // post that hung for minutes on a first-enable image pull and then flashed „Beállítás mentve." // regardless of whether anything actually came up. @@ -411,6 +426,15 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() name := strings.TrimSpace(r.FormValue("name")) + // SERVER-SIDE refusal for controller-owned shares (R-75), BEFORE any mutation. The template also + // omits the button; both are required and they prove different things — a render gate is not + // enforcement, and a handler check is not reachability (the v0.70.1 ghost-delete lesson). + for _, sh := range s.settings.GetSMBShares() { + if strings.EqualFold(sh.Name, name) && sh.System { + sharingRedirect(w, r, "Ez a megosztás a rendszer része, nem törölhető.") + return + } + } if err := s.settings.RemoveSMBShare(name); err != nil { sharingRedirect(w, r, err.Error()) return @@ -524,3 +548,40 @@ func writeSharingJSON(w http.ResponseWriter, code int, v interface{}) { fmt.Fprintf(w, `{"error":"encode"}`) } } + +// ensureImportShare creates the controller-owned drop-zone share (R-75) if it is not already there. +// Idempotent, and a no-op when the import root is unresolvable. +// +// It writes to the store DIRECTLY rather than going through sharingResolvePath: that guard validates +// paths a CUSTOMER supplied through the picker, and refuses anything outside a registered storage +// root. The system drive is deliberately not registered (registering it would make a 50 GB volume +// holding the recovery units a customer-visible drive, a deploy target and a wipe candidate), so the +// guard would refuse this path — correctly, for customer input. A controller-generated constant is a +// different trust class. +// +// Offsite is FALSE: the drop-zone is class `excluded` data, and shipping an inbox offsite would +// contradict the class the backup engines already act on. +func (s *Server) ensureImportShare() error { + if s.stackMgr == nil { + return nil + } + root := s.stackMgr.GetImportRoot() + if root == "" { + return nil + } + for _, sh := range s.settings.GetSMBShares() { + if strings.EqualFold(sh.Name, settings.SystemImportShareName) { + return nil // already present + } + } + if err := s.stackMgr.EnsureImportRoot(); err != nil { + return err + } + return s.settings.AddSMBShare(settings.SMBShare{ + Name: settings.SystemImportShareName, + Path: root, + ReadOnly: false, + Offsite: false, + System: true, + }) +} diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index 68a8518..2714d7e 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -279,7 +279,7 @@ func (s *Server) registerStoragePath(where, label string, setDefault bool) error // v0.66.0: create the full userdata skeleton with the shared-storage convention (2775 setgid, // gid 1000) the moment a drive is registered — system drive AND additional drives. Idempotent; // best-effort (a perms hiccup shouldn't block registration). - if err := appbackup.EnsureUserdataSkeleton(where); err != nil { + if err := s.ensureUserdataSkeleton(where); err != nil { s.logger.Printf("[WARN] [web] userdata skeleton on %s: %v", where, err) } // Change 4: re-enrolling a previously-DECOMMISSIONED drive must un-retire it. AddStoragePath @@ -890,3 +890,19 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) { } writeDiskJSON(w, http.StatusOK, true, "", res) } + +// ensureUserdataSkeleton applies the CATALOG-DERIVED userdata skeleton (R-75) to a storage path. +// +// It replaces the direct appbackup.EnsureUserdataSkeleton call, which used a hardcoded Go list, so a +// new catalog app with a folder no longer needs a controller release. The derived set is merged with +// appbackup.UserdataSkeletonCarry (which is the old hardcoded list verbatim), so the result can only +// ever ADD — no directory this arc touches is ever removed. +// +// Falls back to the carry-list alone when the stack manager is not wired (setup mode / tests), which +// is exactly the pre-R-75 behaviour. +func (s *Server) ensureUserdataSkeleton(nsRoot string) error { + if s.stackMgr == nil { + return appbackup.EnsureUserdataSkeleton(nsRoot, appbackup.BuildUserdataSkeleton(nil)) + } + return s.stackMgr.EnsureUserdataSkeleton(nsRoot) +} diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 1afe4b2..ab5852d 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -71,6 +71,24 @@ onerror="this.style.display='none'"> +{{if .DataPathCards}} +
+

Hova tegyem a fájlokat?

+

Ezeket a mappákat a Fájlkezelőben éred el. Előfordulhat, hogy először be kell jelentkezned a Fájlkezelőbe.

+
+ {{range .DataPathCards}} +
+
+ {{.Label}} ↗ + {{if .FreeSpace}}{{.FreeSpace}}{{end}} +
+ {{if .Consequence}}

{{.Consequence}}

{{end}} +
+ {{end}} +
+
+{{end}} + {{if and .Stack.Deployed .MigrateTargets}}

Áthelyezés másik tárhelyre

diff --git a/controller/internal/web/templates/sharing.html b/controller/internal/web/templates/sharing.html index eaabc13..c853313 100644 --- a/controller/internal/web/templates/sharing.html +++ b/controller/internal/web/templates/sharing.html @@ -146,11 +146,15 @@ + {{if .System}} + rendszer + {{else}}
{{$.CSRFField}}
+ {{end}} {{end}} diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index bb0357f..a85f9d7 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -1317,6 +1317,44 @@ a.stat-card:hover { font-size: .95rem; color: var(--text-1); } +/* „Hova tegyem a fájlokat?" — R-75 folder rows on the app page. */ +.datapath-list { + display: flex; + flex-direction: column; + gap: .75rem; +} +.datapath-row { + border-left: 3px solid var(--blue); + background: var(--blue-dim); + padding: .6rem .75rem; + border-radius: var(--radius); +} +.datapath-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: .75rem; + flex-wrap: wrap; +} +.datapath-link { + color: var(--blue-bright); + font-weight: 600; + text-decoration: none; +} +.datapath-link:hover { + text-decoration: underline; +} +.datapath-space { + color: var(--text-2); + font-size: .8rem; + white-space: nowrap; +} +.datapath-note { + margin: .35rem 0 0 0; + color: var(--text-2); + font-size: .85rem; + line-height: 1.45; +} .app-info-list { margin: 0; padding-left: 1.25rem;