From bb56b1db4bf6675f69cb1862b33c38cfb8d50241 Mon Sep 17 00:00:00 2001 From: librelad Date: Mon, 24 Aug 2026 03:35:01 +0100 Subject: [PATCH] docs(roadmap): fitness checks, first-run wizard step, config panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three surfaces the plan was missing, and the check suite that gates them. §6 splits validation in two, which is the point: admission (§3) is a security question answered by the root helper and it refuses; fitness is "will app data actually work here", needs no privilege, and therefore can be run speculatively against a disk the user hasn't chosen — which is what lets the wizard grade candidates before anything is committed. Ten checks, of which two are worth calling out: a real chown probe, because NFS root_squash reports a healthy nfs4 and then can't chown; and reboot persistence, because hand-mount → register → install Nextcloud → reboot is §10.1's disaster on day one. Checks recur on the crontab rather than only gating at add time — a location that passed in March can be full, read-only or unplugged by June. §7 adds the setup-wizard Storage step. It follows the Metrics precedent for a conditional step but with a better trigger: only appear if the box has somewhere else to put things, so the one-disk case is untouched. Candidate detection has to filter hard — this dev box has ~15 snap loop devices to drop before the real answer shows. Refused candidates render greyed with the reason rather than vanishing. §5.2 puts the resolved path in the field's comment for hand-recovery, written only on change (install, move, or a regen that finds it stale) so it isn't churn on a user-editable file in the container-owned tree. It's a breadcrumb: nothing reads it to decide anything, and the WebUI panel composes its description from appDir so the editor is right even when the comment is stale. Phasing regrouped to 7 phases; §10.4 folded into §6; open question 5 resolved (build the flag — unattended installs have no wizard). Co-Authored-By: Claude Opus 5 --- docs/roadmap/storage-locations.md | 120 ++++++++++++++++++++++-------- 1 file changed, 91 insertions(+), 29 deletions(-) diff --git a/docs/roadmap/storage-locations.md b/docs/roadmap/storage-locations.md index 5eb2094..570cb93 100644 --- a/docs/roadmap/storage-locations.md +++ b/docs/roadmap/storage-locations.md @@ -18,7 +18,7 @@ Nextcloud's 4 TB of photos go on the spinning disk. Vaultwarden and the control - ❌ A general volume manager. We don't format, partition, mount, or write `/etc/fstab`. The drive must already be mounted; we validate and use it. - ❌ Striping/tiering/RAID-alikes. One app's data lives on exactly one location. No splitting an app across two. - ❌ Per-*volume* placement inside an app (`./data` here, `./db` there). Location granularity is the app directory. Revisit only if a real need shows up. -- ❌ Making backup locations and storage locations the same registry. They overlap on disk but differ in trust, lifecycle and ownership (see §9, §11.2). +- ❌ Making backup locations and storage locations the same registry. They overlap on disk but differ in trust, lifecycle and ownership (see §11, §13.2). ## 2. What already works in our favour @@ -67,8 +67,8 @@ That single file then does **three** jobs, which is the main reason to like it: | Job | How | |---|---| | Admission | "empty or marked" — §3 | -| **Mount detection** | the marker lives *on the drive*. Not mounted ⇒ bare mountpoint ⇒ no marker ⇒ location unavailable. No `findmnt`, no `fs_uuid` bookkeeping, and it works identically for USB disks, network mounts, and LUKS volumes that haven't been unlocked (§8.1) | -| Provenance on migrate | it carries the install id and location id the snapshot's manifest names, so "is this the same `bigdisk` the app came from?" is a file read (§7) | +| **Mount detection** | the marker lives *on the drive*. Not mounted ⇒ bare mountpoint ⇒ no marker ⇒ location unavailable. No `findmnt`, no `fs_uuid` bookkeeping, and it works identically for USB disks, network mounts, and LUKS volumes that haven't been unlocked (§10.1) | +| Provenance on migrate | it carries the install id and location id the snapshot's manifest names, so "is this the same `bigdisk` the app came from?" is a file read (§9) | **Residual wrinkle, not solved by elegance:** validate-then-`chown` is a TOCTOU window, and bash is a poor language for race-free path handling. The practical closure is to additionally require that the **parent** directory is not manager-writable — true for `/mnt`, `/srv`, `/media`, false for a path inside the manager's home. That's a real restriction on where locations may live, not a free lunch, and it should be stated in the docs rather than hidden. @@ -84,7 +84,7 @@ pathIsContainerData

# is this path under ANY container root? - **`appDir`** builds a `slug → dir` map once per process by scanning each enabled root for `*/.config`, memoises it in an associative array, and falls back to the primary root for a slug that doesn't exist yet (fresh install). Discovery-first, config-second: if the map and `CFG__STORAGE` disagree, **the directory on disk wins** and we warn. That's what makes the system self-healing after a hand-move or a half-finished migration. - It is also **the single place the availability check belongs**. Every one of the ~200 sites has to call it, so a location whose drive is missing fails *once*, centrally, instead of needing a guard sprinkled at each caller. `appDir` returns non-zero **and** prints an unusable sentinel path, so the many callers that won't check `$?` still fail loudly on a path that cannot exist, rather than writing into a bare mountpoint. See §8.1. + It is also **the single place the availability check belongs**. Every one of the ~200 sites has to call it, so a location whose drive is missing fails *once*, centrally, instead of needing a guard sprinkled at each caller. `appDir` returns non-zero **and** prints an unusable sentinel path, so the many callers that won't check `$?` still fail loudly on a path that cannot exist, rather than writing into a bare mountpoint. See §10.1. - **`pathIsContainerData`** replaces the `[[ "$p" == "$containers_dir"* ]]` idiom that decides manager-vs-container-user elevation. It appears in ~10 files (`create_folder.sh`, `create_touch.sh`, `copy_file.sh`, `copy_files.sh`, `copy_folder.sh`, `copy_folders.sh`, `move_file.sh`, `runCfgOp`, `tags_manager_update.sh`, `webui_atomic_write.sh`). **Every one is a silent-corruption bug if missed** — a file under an unrecognised root gets written as the manager, lands with the wrong owner, and the container fails to read it at a moment far removed from the cause. Scale of the sweep (measured, not estimated): @@ -93,7 +93,7 @@ Scale of the sweep (measured, not estimated): |---|---|---| | `$containers_dir$app`-shaped, app-scoped | ~186 across ~90 files | → `$(appDir "$app")` | | `$containers_dir/libreportal/…` (the WebUI's own tree) | 63 | → `webuiDir` helper, **pinned to the primary root** | -| `$containers_dir/{traefik,prometheus,grafana,adguard,…}` — one app reaching into another by literal name | ~29 | → `appDir `; those apps stay pinned in phase 1 (§8.3) | +| `$containers_dir/{traefik,prometheus,grafana,adguard,…}` — one app reaching into another by literal name | ~29 | → `appDir `; those apps stay pinned in phase 1 (§10.3) | | root-helper `$CONTAINERS_DIR/$app` | `libreportal-ownership`, `libreportal-appcfg` | → registry lookup inside the helper | | the `== "$containers_dir"*` elevation test | ~10 | → `pathIsContainerData` | @@ -112,7 +112,7 @@ CFG_BOOKSTACK_STORAGE=default - The value is a **location name**, not a path. Names are what survive a migrate to a host whose disks are laid out differently; paths aren't. `default` always resolves to the install-time containers root. - It renders as a dropdown on the app's config page for free — the WebUI config renderer already builds selects from the `[a:A|b:B]` comment convention; the option list is generated from the registry into the config comment on regen. -- Changing it in the config editor does **not** move data. It records intent; the move is `libreportal app move` (§6). The config page shows the current *resolved* directory next to the field, and flags a mismatch. +- Changing it in the config editor does **not** move data. It records intent; the move is `libreportal app move` (§8). The config page shows the current *resolved* directory next to the field, and flags a mismatch. - `configBackfillMissingKeys` carries the new key into already-installed apps on the next update, so nothing needs a reinstall. - **New compose tag** `#LIBREPORTAL|APP_DIR_TAG|APP_DIR_DATA`, filled in `dockerConfigSetupFileWithData` alongside the existing `CONTAINERS_DIR_TAG`, for the rare template that genuinely needs its own absolute host path. Most apps need nothing — relative volumes already do the right thing. @@ -123,11 +123,71 @@ A path reads better in isolation — `grep STORAGE *.config` would tell you wher - **Mount paths move.** External drives arrive at `/media//