From 5efdae016770d15adc3014bafb3afbd862533729 Mon Sep 17 00:00:00 2001 From: librelad Date: Mon, 24 Aug 2026 03:49:12 +0100 Subject: [PATCH] docs(roadmap): removable drives warn, never block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reboot-persistence and hot-plug become loud durable warnings instead of refusals, and the --allow-transient flag goes away — there is nothing left for it to unlock. The reasoning, recorded in §6.1 so it isn't relitigated: "not in fstab" and "removable" describe a supported setup, not a broken one — the media library on a USB disk is a reason to want this feature at all. Refusing there fails an honest user on their first reasonable attempt and teaches them to hunt for an override rather than read the warning. We can afford to allow it because the dangerous moment is start-up, not registration, and start-up is already gated: no marker, no location, appDir fails and dockerComposeUp refuses. Blocking at registration buys nothing the runtime doesn't already enforce. So the obligation moves from prevent to inform, and "loud" has to mean durable rather than a toast: the consequence in plain words plus a ready-to-copy fstab line at registration, a standing badge on the location for as long as it holds, and a start-up refusal that names the cause instead of failing generically. Fitness checks now refuse only when a location cannot work at all (checks 1-5), never for merely needing care. Wizard cards get three distinct states, with warned fully selectable and explicitly not second-class. Marked the §10.1 gate load-bearing: it is now the only thing between an unmounted disk and an app rebuilding itself empty, so anything that relaxes it has to revisit §6.1 as well. Co-Authored-By: Claude Opus 5 --- docs/roadmap/storage-locations.md | 33 ++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/roadmap/storage-locations.md b/docs/roadmap/storage-locations.md index 570cb93..5131e2c 100644 --- a/docs/roadmap/storage-locations.md +++ b/docs/roadmap/storage-locations.md @@ -146,7 +146,7 @@ Rules that keep this from becoming a liability: Admission (§3) answers *"is it safe for root to accept this path?"*. That is a **security** question and it lives in the root helper. It does not answer *"will app data actually work here?"* — a separate, larger question that runs in the manager, needs no privilege, and therefore can run **speculatively on a candidate the user hasn't chosen yet**, which is what lets the wizard (§7) show a verdict per disk before anything is committed. -Split them deliberately: security checks refuse, fitness checks grade. +Split them deliberately: security checks refuse, fitness checks grade. And **a fitness check only refuses when the location cannot work at all** — never merely because it's inconvenient or needs care. A removable drive is a supported configuration, not a mistake to be prevented (§6.1). | # | Check | On failure | Why it matters | |---|---|---|---| @@ -155,8 +155,8 @@ Split them deliberately: security checks refuse, fitness checks grade. | 3 | **Real ownership probe** | **refuse** | the decisive test, and the only one that catches NFS `root_squash` — which reports a perfectly respectable `nfs4` at check 1 and then can't `chown`. Create a temp dir, chown it to the container user, `stat` it back, remove it | | 4 | **Sub-UID probe** | **refuse** | rootless docker maps container users into the 100000+ range. Chown the probe to a high uid; some FUSE and network mounts simply can't hold it, and the failure otherwise surfaces much later as an app that won't start | | 5 | **Write / fsync / read-back** | **refuse** | catches a full disk, a flaky USB bridge, a silently-degraded mount | -| 6 | **Persistence across reboot** — `findmnt --fstab`, else a systemd `.mount` | **refuse by default**, override with `--allow-transient` | **the check most worth having.** Hand-mount a disk, register it, install Nextcloud, reboot → §10.1's disaster on day one. We don't write `fstab` (§1) but we must *notice*, and say exactly what to add | -| 7 | **Removable / hot-plug** — `lsblk -o RM,HOTPLUG` | warn + force marker discipline | an external drive is legitimate, but the user should be told once, loudly, what happens when it's absent | +| 6 | **Persistence across reboot** — `findmnt --fstab`, else a systemd `.mount` | **loud, durable warning — never refuse** | hand-mount a disk, register it, install Nextcloud, reboot, and the apps there won't start. That's worth saying clearly and repeatedly; it isn't worth blocking (§6.1). We don't write `fstab` (§1), but we say exactly what to add | +| 7 | **Removable / hot-plug** — `lsblk -o RM,HOTPLUG` | **loud, durable warning** + marker discipline | an external drive is a supported setup. The user gets told plainly what happens when it's absent — once at registration, and standing on the location afterwards | | 8 | **Distinct device** — compare `st_dev` with the primary root | warn | same disk ⇒ the location buys nothing. Usually a misunderstanding, not an error | | 9 | **Free space** | refuse under a floor, warn under a fraction | | | 10 | **Encryption at rest** — is it a LUKS/dm-crypt mapping? | informational | privacy-first product; worth surfacing, never worth blocking | @@ -166,13 +166,34 @@ Two more things this wants: - **`libreportal storage check []`** runs the whole table against a registered location, and against an arbitrary path in *dry-run* mode (`--candidate`) so the wizard, the CLI and the WebUI all share one implementation. It fits the existing `scripts/checks/requirements/check_*.sh` convention. - **It must repeat, not just gate at add time.** A location that passed in March can be full, remounted read-only, unplugged, or backed by a dying disk in June. Put `storage check` on the existing crontab alongside the other periodic checks and surface degradation as a dashboard warning — the same way a failing backup surfaces today. +### 6.1 — Why a non-persistent or removable drive warns instead of refusing + +Blocking these was tempting and would have been wrong. "This drive isn't in `fstab`" and "this drive is removable" both describe a **supported setup** — the media library on the USB disk is one of the reasons to want this feature at all — not a broken one. A refusal there fails the honest user on their first attempt to do something reasonable, and teaches them to look for an override flag rather than to read the warning. + +The reason we can afford it: **the dangerous moment is start-up, not registration, and start-up is already gated.** The marker file (§3.1) means a location whose drive is absent simply isn't available; `appDir` fails and `dockerComposeUp` refuses (§10.1). An app on an unmounted drive doesn't quietly rebuild itself empty — it doesn't start. Given that gate, refusing at registration is belt-and-braces that costs a legitimate use case and buys nothing the runtime doesn't already enforce. + +So the obligation moves from *prevent* to *inform*, and "loud" has to mean **durable**, not a toast that scrolls away: + +- an explicit warning on the wizard/CLI card at registration, stating the consequence in plain words — *"apps stored here will not start until this drive is mounted"* — plus the exact `fstab` line (or `systemd.mount` unit) that would make it permanent, ready to copy +- a **standing badge** on the location in the WebUI Storage page and in `storage list`, for as long as the condition holds. Six months later, when an app won't start, the reason should be visible without archaeology +- the resulting start-up refusal must name the cause: *"nextcloud is on location `bigdisk`, which is not mounted"* — never a generic failure +- `storage check` re-reports it on its periodic run, so a drive that *was* in `fstab` and silently stopped being one gets caught + +A one-time acknowledgement at registration ("I understand apps here won't start unless this drive is mounted") is probably worth it too — it makes the choice deliberate without making it hard. That's a UX call, not an architectural one. + ## 7. Surfaces — first-run wizard, config panels, CLI **First-run wizard.** The setup wizard (`core/setup/js/setup-wizard.js`) currently runs `Experience → Identity → Domains → Recommended → Metrics`, and already has the pattern for a step that isn't always shown: Metrics is advanced-only, and `_effectiveTotalSteps()` makes the count dynamic. A **Storage** step slots in **before Recommended** — locations must exist before apps get placed on them — and follows the same conditional rule, with a better trigger: > **Only show it if the box actually has somewhere else to put things.** One disk, no candidates ⇒ the step never appears, and the 90 % case is unchanged. -Candidate detection is `lsblk` + `findmnt`, filtering the noise aggressively — `loop`/`squashfs`/snap mounts, `/boot`, `/boot/efi`, swap, `tmpfs`, `overlay`, anything under `/proc`, `/sys`, `/run`, and the filesystem already holding the primary root. On this dev box that filter has to drop ~15 snap loop devices to find the real answer, so it isn't optional. Each surviving candidate renders as a card: device, size, free, filesystem, mountpoint, and its §6 verdict — green / warned-with-reason / refused-with-reason. A refused candidate is shown **greyed with the reason**, never hidden; "why isn't my drive listed?" is a support burden we don't need. +Candidate detection is `lsblk` + `findmnt`, filtering the noise aggressively — `loop`/`squashfs`/snap mounts, `/boot`, `/boot/efi`, swap, `tmpfs`, `overlay`, anything under `/proc`, `/sys`, `/run`, and the filesystem already holding the primary root. On this dev box that filter has to drop ~15 snap loop devices to find the real answer, so it isn't optional. Each surviving candidate renders as a card: device, size, free, filesystem, mountpoint, and its §6 verdict — green / warned / refused. The three behave differently on purpose: + +- **green** — selectable, no friction +- **warned** — fully selectable, with the consequence stated on the card and carried forward as a standing badge (§6.1). A USB drive lands here. It is *not* second-class; it's the reason the feature exists +- **refused** — shown **greyed with the reason**, never hidden. "Why isn't my drive listed?" is a support burden we don't need, and the reason ("exFAT can't store file ownership") is usually actionable + +Refusals are reserved for locations that genuinely cannot work (checks 1–5). Nothing is blocked for merely needing care. Registering from the wizard goes through the task system → CLI → `libreportal-storage` helper, exactly like every other mutating action. No chicken-and-egg: by the time the wizard runs, the manager is de-sudo'd but the helper is already in its scoped allowlist. @@ -241,6 +262,8 @@ Where it's enforced: - boot reconcile skips those apps with a loud notice and a dashboard warning rather than failing silently - keep `fs_uuid` in the registry anyway, but as *diagnostics* ("this is a different disk than the one registered"), not as the liveness test +**This gate is load-bearing.** Because registration deliberately does not block removable or non-persistent drives (§6.1), it is the *only* thing standing between an unmounted disk and an app rebuilding itself empty on the bare mountpoint. Weakening it — a "just start anyway" escape hatch, a caller that resolves a path without going through `appDir` — re-arms the exact failure §6.1 assumes is impossible. Anything that relaxes it has to revisit that decision too. + **10.2 — Missing an elevation-test site** (§4) produces wrong-owner files that fail much later. The sentinel-plus-linter approach in §4 is the mitigation: make the survivors fail loudly during the sweep, then make the pattern un-reintroducible. **10.3 — Cross-app literal paths.** Traefik, Prometheus, Grafana, AdGuard, Gluetun, CrowdSec, Headscale are reached into by name from other apps' code. They're also small and infrastructural, so pinning them to the primary location costs a user nothing real. @@ -293,5 +316,5 @@ Each phase is independently shippable and independently verifiable. Phase 0 carr 3. **Does the `libreportal` app itself ever get to move?** Currently pinned. If the primary root fills up, that's a reinstall — acceptable, or worth solving? 4. **Per-instance vs per-type placement.** Instances inherit the model for free, but should `instance create` offer a location up front (likely yes — "work Nextcloud on the big disk, family on the SSD" is a good demo)? 5. ~~**`--storage-dir=` install flags**~~ — **resolved:** build it. The wizard covers the interactive case, but unattended installs have no wizard, and the flag is a few lines once the helper exists (§7). -6. **How hard should check 6 (reboot persistence) refuse?** A hard refuse is right for a fixed internal disk and arguably wrong for "I plug this in when I want Jellyfin". `--allow-transient` covers it, but the default matters — and defaulting to refuse means a user with a genuinely removable drive hits a wall on their first attempt. +6. ~~**How hard should check 6 (reboot persistence) refuse?**~~ — **resolved (2026-08-24):** it doesn't. Warn loudly and durably, never block; removable drives are a supported setup, and start-up is already gated by the marker test. No `--allow-transient` flag — there's nothing left for it to unlock. See §6.1. 7. **Docs promise.** `docs/guide/install-and-use.md` currently states the roots are *"chosen at install and fixed afterward … part of the security model."* That stays true of the three roots; the guide needs a paragraph explaining that additional **containers** locations are addable at runtime, and why the empty-directory rule keeps that honest.