feat(storage): readable drive cards, a details modal, and an fstab offer
The Storage step was a technical dump: every check's full sentence concatenated onto the card, so the fstab line the user is meant to act on was buried in prose nobody reads. The card now shows plain facts and at most two short flags — "Low on space · Won't be mounted after a reboot" — with everything else behind a Details button. The modal carries the technical spec (device, UUID, mount options, removable), every check with its full explanation, and the fstab offer. That needed the shell to stop joining checks into one string: the generator emits a record per check, plus the fstab line as its own field, so neither the card nor the modal has to parse anything back out of the other. The screenshot caught a bug this restructure introduced: summaries keyed on check id alone, so a PASSING check printed the failure wording next to a green tick — "This drive's format can't store file ownership" above "Filesystem: ext4". Now severity-aware. On writing /etc/fstab — §1 ruled it out and §6.3 now records why that reverses. The warning is useless to the audience this is for: "add this line to fstab" assumes SSH, root, an editor, and knowing what fstab is, and the likely outcome is a reboot where nothing starts. What makes it defensible is nofail + x-systemd.device-timeout, which mean a missing device can never block boot — without that pair it would stay a non-goal, because the failure being risked (an unbootable machine) is worse than the one being fixed. Enforced in the root helper: UUID never /dev/sdX, append inside a marked block, refuse a target or UUID already described, refuse the root filesystem, require a live mount, timestamped backup, and `findmnt --verify` before the file is installed — a file that doesn't parse never reaches /etc. Opt-in only. Verified against a real filesystem: entry added and verifies, the persistence warning then disappears on the next scan, and duplicate / root-fs / non-mountpoint / relative are each refused with the reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
f5238f21ca
commit
7eb6d36d55
@ -1248,3 +1248,82 @@ body.setup-wizard-open {
|
||||
.setup-storage-disabled input {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Card: a Details button sits at the right of the row, outside the label's
|
||||
click target so pressing it opens the modal instead of toggling the tick. */
|
||||
.setup-storage-card { position: relative; padding-right: 96px; }
|
||||
.setup-storage-details {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 5px 12px;
|
||||
font-size: 0.82em;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.setup-storage-details:hover { background: rgba(255, 255, 255, 0.16); }
|
||||
.setup-storage-flags { opacity: 0.9; }
|
||||
|
||||
/* Modal */
|
||||
.setup-modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 10000;
|
||||
background: rgba(4, 20, 38, 0.62);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.setup-modal {
|
||||
width: min(680px, 100%);
|
||||
max-height: 82vh;
|
||||
overflow: auto;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(12, 48, 84, 0.97);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.setup-modal-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.setup-modal-title { font-weight: 700; font-size: 1.05em; word-break: break-all; }
|
||||
.setup-modal-close {
|
||||
background: none; border: none; color: inherit;
|
||||
font-size: 1.6em; line-height: 1; cursor: pointer; opacity: 0.75;
|
||||
}
|
||||
.setup-modal-close:hover { opacity: 1; }
|
||||
.setup-modal-body { padding: 18px 20px 22px; }
|
||||
|
||||
.setup-storage-spec { width: 100%; border-collapse: collapse; margin-bottom: 18px; }
|
||||
.setup-storage-spec th {
|
||||
text-align: left; font-weight: 500; opacity: 0.72;
|
||||
padding: 5px 12px 5px 0; white-space: nowrap; vertical-align: top;
|
||||
}
|
||||
.setup-storage-spec td { padding: 5px 0; word-break: break-all; }
|
||||
.setup-storage-spec code { font-size: 0.9em; }
|
||||
|
||||
.setup-storage-checks { list-style: none; margin: 0 0 4px; padding: 0; }
|
||||
.setup-storage-check {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
padding: 9px 0; border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.setup-storage-check-icon { flex: 0 0 auto; }
|
||||
.setup-storage-check-detail { opacity: 0.8; font-size: 0.92em; }
|
||||
|
||||
.setup-storage-fstab {
|
||||
margin-top: 18px; padding: 14px 16px; border-radius: 10px;
|
||||
border: 1px solid rgba(224, 168, 0, 0.32);
|
||||
background: rgba(224, 168, 0, 0.09);
|
||||
}
|
||||
.setup-storage-fstab-title { font-weight: 700; margin-bottom: 6px; }
|
||||
.setup-storage-fstab p { margin: 0 0 10px; font-size: 0.93em; opacity: 0.9; }
|
||||
.setup-storage-fstab pre {
|
||||
margin: 0 0 10px; padding: 9px 11px; overflow-x: auto;
|
||||
border-radius: 7px; background: rgba(0, 0, 0, 0.3); font-size: 0.85em;
|
||||
}
|
||||
.setup-storage-fstab-opt { display: flex; gap: 9px; align-items: center; cursor: pointer; }
|
||||
.setup-storage-fstab-note { margin-top: 10px !important; font-size: 0.85em !important; opacity: 0.75 !important; }
|
||||
|
||||
@ -29,6 +29,8 @@ class SetupWizard {
|
||||
this.hasStorageCandidates = false;
|
||||
this.storageCandidates = [];
|
||||
this.selectedStorage = [];
|
||||
// Paths the user asked us to make permanent in /etc/fstab.
|
||||
this.fstabWanted = [];
|
||||
this.installLevel = 'beginner';
|
||||
this.totalSteps = this._effectiveTotalSteps();
|
||||
this.domainCount = 0; // tracked dynamically as the user adds rows
|
||||
@ -407,6 +409,44 @@ class SetupWizard {
|
||||
this.showStep(this.currentStep);
|
||||
}
|
||||
|
||||
// Short, human summary for a check. The full sentence lives in the details
|
||||
// modal; a card that dumps every message becomes a wall of prose nobody
|
||||
// reads, which is exactly what this replaces.
|
||||
//
|
||||
// Severity-aware, and it has to be: keying on the check id alone printed the
|
||||
// FAILURE wording next to a green tick — "This drive's format can't store
|
||||
// file ownership" above "Filesystem: ext4" — which says the opposite of what
|
||||
// the check found.
|
||||
_storageCheckSummary(c) {
|
||||
if (c.severity === 'info') {
|
||||
const ok = {
|
||||
'fstype': 'Filesystem is supported',
|
||||
'ownership': 'Ownership and permissions work',
|
||||
'persistence': 'Mounts automatically at boot',
|
||||
'space': 'Plenty of space',
|
||||
'encryption': 'Encrypted at rest',
|
||||
'mount-nosuid': 'Mounted nosuid',
|
||||
};
|
||||
return ok[c.id] || c.message;
|
||||
}
|
||||
const map = {
|
||||
'fstype': "This drive's format can't store file ownership",
|
||||
'mount-ro': 'Mounted read-only',
|
||||
'mount-noexec': 'Mounted noexec',
|
||||
'ownership': "Can't set file ownership here",
|
||||
'subuid': "Can't store the IDs containers need",
|
||||
'write': 'Write test failed',
|
||||
'readback': 'Read-back test failed',
|
||||
'writable': "Can't create files here",
|
||||
'space': c.severity === 'refuse' ? 'Not enough space' : 'Low on space',
|
||||
'persistence': "Won't be mounted after a reboot",
|
||||
'removable': 'Removable drive — apps pause when unplugged',
|
||||
'same-device': 'Same disk as the system',
|
||||
'shared-fate': 'Shares a disk with your backups',
|
||||
};
|
||||
return map[c.id] || c.message;
|
||||
}
|
||||
|
||||
renderStorage() {
|
||||
const list = this.container.querySelector('#sw-storage-list');
|
||||
const note = this.container.querySelector('#sw-storage-note');
|
||||
@ -422,14 +462,18 @@ class SetupWizard {
|
||||
const refused = c.verdict === 'refuse';
|
||||
const warned = c.verdict === 'warn';
|
||||
const id = `sw-storage-${i}`;
|
||||
// Checks are joined with "; " on the shell side. Split them back out:
|
||||
// a drive can trip several at once, and one run-on paragraph buries the
|
||||
// fstab line the user is meant to copy.
|
||||
const detailParts = String((refused ? c.refusals : c.warnings) || '')
|
||||
.split(/;\s+/).map(s => s.trim()).filter(Boolean);
|
||||
const badge = refused
|
||||
? '<span class="setup-storage-badge setup-storage-badge-bad">unusable</span>'
|
||||
? '<span class="setup-storage-badge setup-storage-badge-bad">can\u2019t be used</span>'
|
||||
: (warned ? '<span class="setup-storage-badge setup-storage-badge-warn">needs care</span>' : '');
|
||||
|
||||
// One line of plain facts, then at most two short flags. Everything else
|
||||
// is a click away rather than in the user's face.
|
||||
const flags = (c.checks || [])
|
||||
.filter(k => k.severity === 'refuse' || k.severity === 'warn')
|
||||
.map(k => this._storageCheckSummary(k));
|
||||
const shown = flags.slice(0, 2);
|
||||
const more = flags.length - shown.length;
|
||||
|
||||
return `
|
||||
<label class="setup-app setup-storage-card${refused ? ' setup-storage-disabled' : ''}">
|
||||
<input type="checkbox" id="${id}" data-storage-path="${this.escapeHtml(c.path)}" ${refused ? 'disabled' : ''}>
|
||||
@ -438,20 +482,116 @@ class SetupWizard {
|
||||
${this.escapeHtml(c.path)} ${badge}
|
||||
</span>
|
||||
<span class="setup-app-desc">
|
||||
${this.escapeHtml(c.fstype)} · ${this.escapeHtml(c.size)} (${this.escapeHtml(c.free)} free)${c.removable ? ' · removable' : ''}
|
||||
${detailParts.length ? detailParts.map(d => `<br><em>${this.escapeHtml(d)}</em>`).join('') : ''}
|
||||
${this.escapeHtml(c.size)} drive · ${this.escapeHtml(c.free)} free · ${this.escapeHtml(c.fstype)}${c.removable ? ' · removable' : ''}
|
||||
${shown.length ? `<br><span class="setup-storage-flags">${shown.map(f => this.escapeHtml(f)).join(' · ')}${more > 0 ? ` · +${more} more` : ''}</span>` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" class="setup-storage-details" data-storage-details="${i}">Details</button>
|
||||
</label>`;
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('[data-storage-details]').forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.showStorageDetails(Number(btn.dataset.storageDetails));
|
||||
});
|
||||
});
|
||||
|
||||
if (note) {
|
||||
note.innerHTML = 'A drive you tick is registered as a storage location during install. '
|
||||
+ 'You can add or remove locations later from the CLI (<code>libreportal storage</code>). '
|
||||
+ 'Unusable drives are shown greyed with the reason rather than hidden.';
|
||||
note.innerHTML = 'A drive you tick becomes a storage location during install. '
|
||||
+ 'Drives that can\u2019t hold app data are shown greyed with the reason.';
|
||||
}
|
||||
}
|
||||
|
||||
// Details modal: the technical spec, every check with its full explanation,
|
||||
// and — when the drive isn\u2019t in fstab — the offer to make it permanent.
|
||||
showStorageDetails(idx) {
|
||||
const c = this.storageCandidates[idx];
|
||||
if (!c) return;
|
||||
const esc = (s) => this.escapeHtml(s);
|
||||
|
||||
const specRows = [
|
||||
['Mount point', c.path],
|
||||
['Device', c.device],
|
||||
['Filesystem', c.fstype],
|
||||
['Size', c.size],
|
||||
['Free', c.free],
|
||||
['UUID', c.uuid || '—'],
|
||||
['Mount options', c.options || '—'],
|
||||
['Removable', c.removable ? 'Yes' : 'No'],
|
||||
].map(([k, v]) => `<tr><th>${esc(k)}</th><td><code>${esc(v)}</code></td></tr>`).join('');
|
||||
|
||||
const icon = { refuse: '\u26d4', warn: '\u26a0\ufe0f', info: '\u2705' };
|
||||
const checkRows = (c.checks || []).map(k => `
|
||||
<li class="setup-storage-check setup-storage-check-${esc(k.severity)}">
|
||||
<span class="setup-storage-check-icon">${icon[k.severity] || ''}</span>
|
||||
<span>
|
||||
<strong>${esc(this._storageCheckSummary(k))}</strong><br>
|
||||
<span class="setup-storage-check-detail">${esc(k.message)}</span>
|
||||
</span>
|
||||
</li>`).join('');
|
||||
|
||||
const fstab = c.fstab_line ? `
|
||||
<div class="setup-storage-fstab">
|
||||
<div class="setup-storage-fstab-title">Make this drive mount automatically</div>
|
||||
<p>
|
||||
The system doesn\u2019t mount this drive at boot, so apps stored on it won\u2019t start
|
||||
until it is mounted again. LibrePortal can add it to <code>/etc/fstab</code> for you.
|
||||
</p>
|
||||
<pre><code>${esc(c.fstab_line)}</code></pre>
|
||||
<label class="setup-storage-fstab-opt">
|
||||
<input type="checkbox" data-storage-fstab="${esc(c.path)}"
|
||||
${this.fstabWanted.includes(c.path) ? 'checked' : ''}>
|
||||
<span>Add this line during install</span>
|
||||
</label>
|
||||
<p class="setup-storage-fstab-note">
|
||||
Written with <code>nofail</code>, so if the drive is missing the machine still boots
|
||||
normally. Your current <code>/etc/fstab</code> is backed up first.
|
||||
</p>
|
||||
</div>` : '';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'setup-modal-backdrop';
|
||||
modal.innerHTML = `
|
||||
<div class="setup-modal" role="dialog" aria-modal="true" aria-label="Drive details">
|
||||
<div class="setup-modal-head">
|
||||
<span class="setup-modal-title">${esc(c.path)}</span>
|
||||
<button type="button" class="setup-modal-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="setup-modal-body">
|
||||
<table class="setup-storage-spec">${specRows}</table>
|
||||
<ul class="setup-storage-checks">${checkRows}</ul>
|
||||
${fstab}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const close = () => modal.remove();
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
|
||||
modal.querySelector('.setup-modal-close').addEventListener('click', close);
|
||||
document.addEventListener('keydown', function onKey(e) {
|
||||
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
|
||||
});
|
||||
|
||||
// Remember the fstab choice on the wizard, not in the DOM: the modal is
|
||||
// destroyed on close and the choice has to survive to submit().
|
||||
const fstabBox = modal.querySelector('[data-storage-fstab]');
|
||||
if (fstabBox) {
|
||||
fstabBox.addEventListener('change', () => {
|
||||
const path = fstabBox.dataset.storageFstab;
|
||||
this.fstabWanted = this.fstabWanted.filter(p => p !== path);
|
||||
if (fstabBox.checked) {
|
||||
this.fstabWanted.push(path);
|
||||
// Adding it to fstab only makes sense if the drive is actually used.
|
||||
const own = this.container.querySelector(`[data-storage-path="${CSS.escape(path)}"]`);
|
||||
if (own && !own.disabled) own.checked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.container.appendChild(modal);
|
||||
}
|
||||
|
||||
escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (ch) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]
|
||||
@ -864,7 +1004,9 @@ class SetupWizard {
|
||||
// Paths ticked on the Storage step. Registering one is a privileged
|
||||
// action, so this is only a request — the host applier runs it through
|
||||
// the CLI and the root helper, which validate independently.
|
||||
storage: this.collectStorage()
|
||||
storage: this.collectStorage(),
|
||||
// Only for drives that are actually being registered.
|
||||
storage_fstab: this.fstabWanted.filter(p => this.collectStorage().includes(p))
|
||||
};
|
||||
|
||||
// Apply the experience choice to the WebUI immediately so the next
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# LibrePortal — Storage Locations (per-app data placement)
|
||||
|
||||
**Status:** Phases 0-2, 4 and 5 **built**; the two WebUI surfaces (setup-wizard step, Disks page) are not. · **Audience:** us, future-self · **Scope:** register more than one filesystem root for live app data, choose one per app, move an app between them, and resolve the right one on restore/migrate · **Origin:** "add different locations to set up LibrePortal on, with control per app" (2026-08-24)
|
||||
**Status:** Phases 0-5 **built** (incl. the setup-wizard Storage step); the Disks WebUI page is not. · **Audience:** us, future-self · **Scope:** register more than one filesystem root for live app data, choose one per app, move an app between them, and resolve the right one on restore/migrate · **Origin:** "add different locations to set up LibrePortal on, with control per app" (2026-08-24)
|
||||
|
||||
---
|
||||
|
||||
@ -15,7 +15,8 @@ Nextcloud's 4 TB of photos go on the spinning disk. Vaultwarden and the control
|
||||
## 1. Non-goals
|
||||
|
||||
- ❌ Relocating the **system** root (configs/db/logs) per-app. It stays one place, chosen at install. Same for the WebUI's own container dir.
|
||||
- ❌ 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.
|
||||
- ❌ A general volume manager. We don't format, partition or mount anything — the drive must already be mounted; we validate and use it.
|
||||
- ⚠️ **One exception, added deliberately:** on explicit request we append a single marked `/etc/fstab` entry so a registered drive comes back after a reboot (§6.3). Telling a non-expert "add this line to fstab yourself" is a wall, and the most likely outcome is a reboot where nothing starts.
|
||||
- ❌ 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.
|
||||
- ❌ Merging the backup-location and storage-location **registries**. They differ in trust, lifecycle and ownership, so they stay separate — but they may freely share a *drive*, which is a supported and expected setup (§6.2).
|
||||
@ -209,6 +210,46 @@ Worth noting the compounding case explicitly, because it's the one people don't
|
||||
|
||||
**Consequence for naming.** Sharing makes the collision worse, not better: the user now genuinely sees "bigdisk" in two places meaning two things. That's what the **Disks view** (§7.1) answers — one row per device, with *app data* and *backups* as roles on it rather than as competing top-level nouns. It's also where this section's shared-fate badge and per-device space accounting naturally live.
|
||||
|
||||
### 6.3 — Writing `/etc/fstab`, and why that is allowed here
|
||||
|
||||
§1 originally ruled this out, and reversing that deserves an argument rather
|
||||
than a shrug.
|
||||
|
||||
The case for: the persistence warning (§6, check 6) is useless to the audience
|
||||
this product is for. "Add `UUID=… /mnt/disk ext4 defaults,nofail 0 2` to
|
||||
/etc/fstab" assumes SSH, root, an editor, and knowing what fstab is. Someone who
|
||||
registers a drive, doesn't act on the warning, and reboots gets apps that refuse
|
||||
to start — the exact failure we were trying to prevent, arrived at by a longer
|
||||
road. LibrePortal already writes sysctl drop-ins, `modules-load.d`, systemd
|
||||
units, sudoers and firewall rules; fstab is not a different category of file.
|
||||
|
||||
The case against is real and specific: **a bad fstab entry can leave a machine
|
||||
unbootable**, needing rescue media. That is worse than any other failure mode in
|
||||
this product.
|
||||
|
||||
What makes it defensible is `nofail`, plus `x-systemd.device-timeout=10s`.
|
||||
Together they mean a missing device can never block boot — which is precisely
|
||||
the failure the objection is about. Without that pair this would stay a non-goal.
|
||||
|
||||
The rules, all enforced in the root helper (`libreportal-storage fstab-add`):
|
||||
|
||||
| Rule | Why |
|
||||
|---|---|
|
||||
| `nofail,x-systemd.device-timeout=10s` always | a missing drive can't block boot or strand the box at a systemd timeout |
|
||||
| `UUID=`, never `/dev/sdX` | device names reorder between boots; a stale one mounts the wrong disk or nothing |
|
||||
| append inside a marked block, never rewrite | anything the user or another tool manages is untouched |
|
||||
| refuse if the target or UUID is already described | we don't get to be the second opinion on someone else's mount |
|
||||
| refuse the root filesystem outright | never our business |
|
||||
| must currently be a real mount | we describe reality, we don't invent it |
|
||||
| timestamped backup to `/etc/fstab.libreportal-*.bak` | recoverable |
|
||||
| `findmnt --verify` before install; discard on failure | a file that doesn't parse never reaches `/etc` |
|
||||
| opt-in only | nothing calls it unless a person ticked the box |
|
||||
|
||||
Verified against a real filesystem: the entry is added and verifies, the
|
||||
persistence warning then disappears on the next scan, and a duplicate, the root
|
||||
filesystem, a non-mountpoint and a relative path are each refused with the
|
||||
reason.
|
||||
|
||||
## 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:
|
||||
@ -401,7 +442,7 @@ Each phase is independently shippable and independently verifiable. Phase 0 carr
|
||||
| **0** ✅ | `appDir` / `storageRoots` / `pathIsContainerData` / `webuiDir`; ~260 call sites swept. `scripts/dev/lp-storage-test`. | No |
|
||||
| **1** ✅ | Root-owned registry + `libreportal-storage` + fitness checks + `libreportal storage {list,add,remove,check,scan,apps,disks}`. | CLI only |
|
||||
| **2** ✅ | `CFG_<APP>_STORAGE` in 37 templates + the `**READONLY**` marker + resolved path in the comment (§5.2) + per-location config panel emit. | Yes |
|
||||
| **3** ⬜ | Setup-wizard **Storage** step (§7) + `--storage-dir=`. **Not built.** The headless path it would drive already works, so this is UI on top of a working feature, not a prerequisite. | Yes |
|
||||
| **3** ✅ | Setup-wizard **Storage** step (§7), with a details modal and the opt-in `/etc/fstab` offer (§6.3). `--storage-dir=` for unattended installs is still open. | Yes |
|
||||
| **4** ✅ | `libreportal app move` — stop, snapshot, copy as root, verify, then delete the source. | Yes |
|
||||
| **5** ◐ | Manifest `storage` block and the staged restore + path rewrite are done (§9's latent bug is fixed). The "unknown location" **prompt** is not — an unresolvable location currently falls back to this host's default rather than asking. | Yes |
|
||||
| **6** ◐ | The Disks **data layer** is built and `libreportal storage disks` renders it (including not-attached rows). The WebUI page at `/admin/system/storage` and the periodic `storage check` cron are **not**. | Yes |
|
||||
|
||||
@ -102,6 +102,23 @@ cliHandleStorageCommands()
|
||||
storageDisks
|
||||
;;
|
||||
|
||||
fstab-add)
|
||||
# Make a mount permanent. The root helper does the writing and all
|
||||
# the validating; this is only the front door.
|
||||
if [[ -z "$arg1" ]]; then
|
||||
isNotice "Usage: storage fstab-add <mountpoint>"
|
||||
return 1
|
||||
fi
|
||||
local line
|
||||
if line=$(runStorage fstab-add "$arg1" 2>&1); then
|
||||
isSuccessful "Added to /etc/fstab: $line"
|
||||
isNotice "A backup of the previous file is in /etc/fstab.libreportal-*.bak"
|
||||
else
|
||||
isError "$line"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
|
||||
*)
|
||||
isNotice "Invalid storage action: $action"
|
||||
cliShowStorageHelp
|
||||
|
||||
@ -25,6 +25,11 @@ cliShowStorageHelp()
|
||||
echo "storage apps [id]"
|
||||
echo " Which apps live on a location."
|
||||
echo ""
|
||||
echo "storage fstab-add <mountpoint>"
|
||||
echo " Make a mounted drive permanent by adding one line to /etc/fstab, so"
|
||||
echo " it comes back after a reboot. Written with nofail, so a missing drive"
|
||||
echo " can never block boot; the previous file is backed up first."
|
||||
echo ""
|
||||
echo "storage disks"
|
||||
echo " One row per filesystem, showing what LibrePortal uses it for."
|
||||
echo ""
|
||||
|
||||
@ -25,6 +25,7 @@ setupApplyConfig()
|
||||
local traefik_email=$(echo "$payload" | jq -r '.traefik_email // empty')
|
||||
local domains_json=$(echo "$payload" | jq -c '.domains // []')
|
||||
local storage_json=$(echo "$payload" | jq -c '.storage // []')
|
||||
local storage_fstab_json=$(echo "$payload" | jq -c '.storage_fstab // []')
|
||||
|
||||
if [[ -n "$install_name" ]]; then
|
||||
updateConfigOption "CFG_INSTALL_NAME" "$install_name"
|
||||
@ -68,6 +69,17 @@ setupApplyConfig()
|
||||
[[ -z "$sname" ]] && sname="disk$((i+1))"
|
||||
if storageAdd "$s" "$sname" >/dev/null; then
|
||||
isSuccessful "Storage location '$sname' registered at $s"
|
||||
# Only when explicitly asked on the Storage step. The helper
|
||||
# validates independently and writes with nofail, so a
|
||||
# missing drive can never block boot.
|
||||
if echo "$storage_fstab_json" | jq -e --arg p "$s" 'index($p) != null' >/dev/null 2>&1; then
|
||||
local fline
|
||||
if fline=$(runStorage fstab-add "$s" 2>&1); then
|
||||
isSuccessful "Added to /etc/fstab so it mounts at boot: $fline"
|
||||
else
|
||||
isNotice "Could not update /etc/fstab for $s: $fline"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
isNotice "Could not register '$s' as a storage location — continuing without it."
|
||||
fi
|
||||
|
||||
@ -1020,6 +1020,7 @@ declare -gA LP_FN_MAP=(
|
||||
[storageDisksData]="storage/storage_disks.sh"
|
||||
[_storageEmit]="storage/storage_checks.sh"
|
||||
[_storageFreeHuman]="storage/storage_locations.sh"
|
||||
[storageFstabLine]="storage/storage_checks.sh"
|
||||
[storageList]="storage/storage_locations.sh"
|
||||
[storageLocationConfig]="storage/storage_locations.sh"
|
||||
[storageLocationDir]="storage/storage_locations.sh"
|
||||
@ -2243,6 +2244,7 @@ declare -gA LP_FN_ROOT=(
|
||||
[storageDisksData]="scripts"
|
||||
[_storageEmit]="scripts"
|
||||
[_storageFreeHuman]="scripts"
|
||||
[storageFstabLine]="scripts"
|
||||
[storageList]="scripts"
|
||||
[storageLocationConfig]="scripts"
|
||||
[storageLocationDir]="scripts"
|
||||
@ -3503,6 +3505,7 @@ storageDisks() { unset -f storageDisks; __lpAutoload "${install_scripts_dir}stor
|
||||
storageDisksData() { unset -f storageDisksData; __lpAutoload "${install_scripts_dir}storage/storage_disks.sh"; storageDisksData "$@"; }
|
||||
_storageEmit() { unset -f _storageEmit; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; _storageEmit "$@"; }
|
||||
_storageFreeHuman() { unset -f _storageFreeHuman; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; _storageFreeHuman "$@"; }
|
||||
storageFstabLine() { unset -f storageFstabLine; __lpAutoload "${install_scripts_dir}storage/storage_checks.sh"; storageFstabLine "$@"; }
|
||||
storageList() { unset -f storageList; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; storageList "$@"; }
|
||||
storageLocationConfig() { unset -f storageLocationConfig; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; storageLocationConfig "$@"; }
|
||||
storageLocationDir() { unset -f storageLocationDir; __lpAutoload "${install_scripts_dir}storage/storage_locations.sh"; storageLocationDir "$@"; }
|
||||
|
||||
@ -114,14 +114,38 @@ _storageCheckPersistence()
|
||||
return 0
|
||||
fi
|
||||
|
||||
local src fstype
|
||||
_storageEmit warn persistence "This drive is not listed in /etc/fstab, so the system will not mount it automatically after a reboot. Apps stored here will refuse to start until it is mounted again."
|
||||
# The exact line, as its own record: the UI offers to add it, and a person
|
||||
# reading the CLI can copy it. Kept separate from the prose so neither has
|
||||
# to be parsed out of the other.
|
||||
local line; line=$(storageFstabLine "$probe")
|
||||
[[ -n "$line" ]] && _storageEmit info fstab-line "$line"
|
||||
return 0
|
||||
}
|
||||
|
||||
# The /etc/fstab line that would make this mount permanent.
|
||||
#
|
||||
# UUID= rather than /dev/sdX because device names reorder across reboots, and a
|
||||
# stale one either mounts the wrong disk or nothing at all.
|
||||
#
|
||||
# nofail + x-systemd.device-timeout are what make this safe to add at all: a
|
||||
# missing device then cannot block boot. Without them an absent drive leaves the
|
||||
# machine sitting at a systemd timeout, or worse in emergency mode — a far worse
|
||||
# failure than the app-not-starting one we are trying to prevent.
|
||||
storageFstabLine()
|
||||
{
|
||||
local probe="$1" target src fstype uuid
|
||||
command -v findmnt >/dev/null 2>&1 || return 1
|
||||
target=$(findmnt -no TARGET --target "$probe" 2>/dev/null | tail -1)
|
||||
[[ -z "$target" || "$target" == "/" ]] && return 1
|
||||
src=$(findmnt -no SOURCE --target "$probe" 2>/dev/null | tail -1)
|
||||
fstype=$(findmnt -no FSTYPE --target "$probe" 2>/dev/null | tail -1)
|
||||
local uuid; uuid=$(findmnt -no UUID --target "$probe" 2>/dev/null | tail -1)
|
||||
local line="${src:-<device>} $target ${fstype:-auto} defaults,nofail 0 2"
|
||||
[[ -n "$uuid" ]] && line="UUID=$uuid $target ${fstype:-auto} defaults,nofail 0 2"
|
||||
_storageEmit warn persistence "Not in /etc/fstab: after a reboot this drive will not be mounted, and apps stored here will not start until it is. To make it permanent, add: $line"
|
||||
return 0
|
||||
uuid=$(findmnt -no UUID --target "$probe" 2>/dev/null | tail -1)
|
||||
local dev="${src:-}"
|
||||
[[ -n "$uuid" ]] && dev="UUID=$uuid"
|
||||
[[ -z "$dev" ]] && return 1
|
||||
printf '%s %s %s defaults,nofail,x-systemd.device-timeout=10s 0 2' \
|
||||
"$dev" "$target" "${fstype:-auto}"
|
||||
}
|
||||
|
||||
# 7. Removable / hot-plug. WARNS — an external drive is a supported setup.
|
||||
|
||||
@ -216,6 +216,103 @@ probe() {
|
||||
return $rc
|
||||
}
|
||||
|
||||
# Make a mount permanent by adding one line to /etc/fstab.
|
||||
#
|
||||
# fstab is the most dangerous file LibrePortal touches: a bad entry does not
|
||||
# break an app, it can leave the machine unbootable and needing rescue media.
|
||||
# Everything below exists to make that impossible:
|
||||
#
|
||||
# * nofail + x-systemd.device-timeout=10s — a missing device can then never
|
||||
# block boot. This single pair is what makes writing fstab defensible at
|
||||
# all; without it an unplugged drive strands the box at a systemd timeout
|
||||
# or drops it to emergency mode.
|
||||
# * UUID=, never /dev/sdX — device names reorder between boots.
|
||||
# * append only, inside a marked block. Existing lines are never rewritten,
|
||||
# so anything the user or another tool manages is untouched.
|
||||
# * refuse when a line for that target or device already exists — we do not
|
||||
# get to be the second opinion on a mount someone else configured.
|
||||
# * a timestamped backup, and `findmnt --verify` before the new file is put
|
||||
# in place. A file that does not verify is discarded, not installed.
|
||||
#
|
||||
# Opt-in only: nothing calls this unless a person asked for it.
|
||||
fstab_add() {
|
||||
local target="${1:-}"
|
||||
[[ -n "$target" && "$target" == /* ]] \
|
||||
|| { _err "fstab-add requires an absolute mount point"; return 2; }
|
||||
target="${target%/}"
|
||||
[[ "$target" == *..* ]] && { _err "invalid mount point"; return 2; }
|
||||
|
||||
command -v findmnt >/dev/null 2>&1 || { _err "findmnt unavailable"; return 1; }
|
||||
|
||||
# Refuse the root filesystem before anything else, so the reason given is the
|
||||
# real one rather than a confusing "not a mount point".
|
||||
[[ "$target" == "/" || -z "$target" ]] \
|
||||
&& { _err "refusing to touch the root filesystem's fstab entry"; return 1; }
|
||||
|
||||
# Must currently be a real mount — we describe reality, we do not invent it.
|
||||
local now_target
|
||||
now_target=$(findmnt -no TARGET --target "$target" 2>/dev/null | tail -1)
|
||||
if [[ "$now_target" != "$target" ]]; then
|
||||
_err "'$target' is not a mount point right now. Mount it first, then add it."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local uuid fstype
|
||||
uuid=$(findmnt -no UUID --target "$target" 2>/dev/null | tail -1)
|
||||
fstype=$(findmnt -no FSTYPE --target "$target" 2>/dev/null | tail -1)
|
||||
if [[ -z "$uuid" ]]; then
|
||||
_err "'$target' has no filesystem UUID, so no stable fstab entry can be written for it."
|
||||
return 1
|
||||
fi
|
||||
[[ -z "$fstype" ]] && fstype="auto"
|
||||
|
||||
# Already described? Leave it alone — do not add a second opinion.
|
||||
local existing
|
||||
existing=$(grep -vE '^[[:space:]]*#' /etc/fstab 2>/dev/null \
|
||||
| awk -v t="$target" -v u="UUID=$uuid" '$2==t || $1==u {print; exit}')
|
||||
if [[ -n "$existing" ]]; then
|
||||
_err "/etc/fstab already has an entry for this mount: $existing"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local line="UUID=$uuid $target $fstype defaults,nofail,x-systemd.device-timeout=10s 0 2"
|
||||
|
||||
local stamp backup tmp
|
||||
stamp=$(date +%Y%m%d-%H%M%S)
|
||||
backup="/etc/fstab.libreportal-$stamp.bak"
|
||||
cp -a /etc/fstab "$backup" || { _err "could not back up /etc/fstab"; return 1; }
|
||||
|
||||
tmp=$(mktemp) || return 1
|
||||
cat /etc/fstab > "$tmp"
|
||||
# Guarantee the file ends with a newline before appending, or the new entry
|
||||
# would be glued onto whatever the last line was.
|
||||
[[ -s "$tmp" && -n "$(tail -c1 "$tmp")" ]] && printf '\n' >> "$tmp"
|
||||
{
|
||||
printf '\n# Added by LibrePortal (%s) — storage location %s\n' "$stamp" "$target"
|
||||
printf '# nofail: a missing device must never block boot.\n'
|
||||
printf '%s\n' "$line"
|
||||
} >> "$tmp"
|
||||
|
||||
# Verify BEFORE installing. findmnt --verify parses fstab and reports
|
||||
# structural problems; a file that does not pass is thrown away.
|
||||
if ! findmnt --verify --tab-file "$tmp" >/dev/null 2>&1; then
|
||||
local why; why=$(findmnt --verify --tab-file "$tmp" 2>&1 | head -5)
|
||||
rm -f "$tmp"
|
||||
_err "the resulting /etc/fstab did not verify, so it was NOT installed: $why"
|
||||
return 1
|
||||
fi
|
||||
|
||||
install -m 0644 -o root -g root "$tmp" /etc/fstab || { rm -f "$tmp"; return 1; }
|
||||
rm -f "$tmp"
|
||||
|
||||
# Let systemd pick up the new unit now, so the entry is live rather than
|
||||
# only true after the next boot.
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
|
||||
echo "$line"
|
||||
return 0
|
||||
}
|
||||
|
||||
add() {
|
||||
local raw="" name="" allow_home=0 a
|
||||
for a in "$@"; do
|
||||
@ -384,9 +481,10 @@ action="${1:-}"; shift 2>/dev/null || true
|
||||
case "$action" in
|
||||
add) add "$@" ;;
|
||||
probe) probe "${1:-}" "${2:-}" ;;
|
||||
fstab-add) fstab_add "${1:-}" ;;
|
||||
remove) remove "${1:-}" ;;
|
||||
list) list ;;
|
||||
path) path "${1:-}" ;;
|
||||
verify) verify "${1:-}" ;;
|
||||
*) echo "usage: libreportal-storage {add <path> [--name=NAME] [--allow-home]|probe <path> [--allow-home]|remove <id|path>|list|path <id>|verify [id]}" >&2; exit 2 ;;
|
||||
*) echo "usage: libreportal-storage {add <path> [--name=NAME] [--allow-home]|probe <path> [--allow-home]|fstab-add <mountpoint>|remove <id|path>|list|path <id>|verify [id]}" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
@ -42,17 +42,26 @@ webuiGenerateStorageCandidates()
|
||||
# Only offer filesystems LibrePortal isn't already using.
|
||||
[[ "$role" == "free" ]] || continue
|
||||
|
||||
local sev check msg refusals="" warnings=""
|
||||
# One record per check rather than two joined strings: the card shows a
|
||||
# short summary, the details modal shows the full text, and neither has
|
||||
# to parse anything back out of the other.
|
||||
local sev check msg checks="[" kfirst=1 verdict="ok" fstab_line=""
|
||||
while IFS=$'\t' read -r sev check msg; do
|
||||
case "$sev" in
|
||||
refuse) refusals+="${refusals:+; }$msg" ;;
|
||||
warn) warnings+="${warnings:+; }$msg" ;;
|
||||
esac
|
||||
[[ -z "$sev" ]] && continue
|
||||
if [[ "$check" == "fstab-line" ]]; then
|
||||
fstab_line="$msg"
|
||||
continue
|
||||
fi
|
||||
[[ "$sev" == "warn" && "$verdict" == "ok" ]] && verdict="warn"
|
||||
[[ "$sev" == "refuse" ]] && verdict="refuse"
|
||||
(( kfirst )) || checks+=","
|
||||
kfirst=0
|
||||
checks+="{\"severity\":\"$(_lpJsonEsc "$sev")\",\"id\":\"$(_lpJsonEsc "$check")\",\"message\":\"$(_lpJsonEsc "$msg")\"}"
|
||||
done < <(storageCheckPath "$target" 2>/dev/null)
|
||||
checks+="]"
|
||||
|
||||
local verdict="ok"
|
||||
[[ -n "$warnings" ]] && verdict="warn"
|
||||
[[ -n "$refusals" ]] && verdict="refuse"
|
||||
local uuid_val; uuid_val=$(findmnt -no UUID --target "$target" 2>/dev/null | tail -1)
|
||||
local opts_val; opts_val=$(findmnt -no OPTIONS --target "$target" 2>/dev/null | tail -1)
|
||||
|
||||
(( cfirst )) || candidates+=","
|
||||
cfirst=0
|
||||
@ -61,10 +70,12 @@ webuiGenerateStorageCandidates()
|
||||
candidates+=",\"fstype\":\"$(_lpJsonEsc "$fstype")\""
|
||||
candidates+=",\"size\":\"$(_lpJsonEsc "$size")\""
|
||||
candidates+=",\"free\":\"$(_lpJsonEsc "$avail")\""
|
||||
candidates+=",\"uuid\":\"$(_lpJsonEsc "$uuid_val")\""
|
||||
candidates+=",\"options\":\"$(_lpJsonEsc "$opts_val")\""
|
||||
candidates+=",\"removable\":$([[ "$rm_flag" == "1" ]] && echo true || echo false)"
|
||||
candidates+=",\"verdict\":\"$verdict\""
|
||||
candidates+=",\"refusals\":\"$(_lpJsonEsc "$refusals")\""
|
||||
candidates+=",\"warnings\":\"$(_lpJsonEsc "$warnings")\"}"
|
||||
candidates+=",\"fstab_line\":\"$(_lpJsonEsc "$fstab_line")\""
|
||||
candidates+=",\"checks\":$checks}"
|
||||
done < <(storageScanCandidates 2>/dev/null)
|
||||
candidates+="]"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user