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.