v0.4.64: NFS mount diagnostics — pre-flight probe, retry, hint translation
The dominant field failure from v0.4.63 was "mount.nfs: failed to apply
fstab options" (exit 32), surfaced verbatim by the Storage tab. The
message is misleading — it has nothing to do with /etc/fstab; it comes
from nfs-utils 2.6.x's nfs_options2string() and most commonly indicates
the container is missing CAP_SYS_ADMIN, /etc/mtab is unwritable, or an
auxiliary option triggered an option-transform edge case.
Backend (crates/iso-store/src/nfs.rs):
- TCP pre-flight probe to server:port (4s timeout) before shelling out.
Catches wrong-IP / firewall cases as "cannot reach NFS port" instead
of letting mount.nfs spit out an unhelpful message.
- proto=tcp explicit on NFSv3 (UDP is widely deprecated, modern NAS
appliances often don't bind UDP at all).
- Optional `port` field on NfsAddRequest (defaults to 2049), persisted
on NfsMount.
- On "failed to apply fstab options" / "internal option parsing error"
retry with a minimal option set (vers=N,ro/rw only) — bypasses the
nfs-utils transformation bug; if it still fails we get a real kernel
error to translate.
- hint_for() translates well-known stderr patterns into actionable
guidance — CAP_SYS_ADMIN for option-transform failures, exports-table
for access-denied, export-path hint for "no such file or directory"
(calling out the UniFi UNAS Pro /var/nfs/shared/<name> convention),
etc.
- normalize_server() strips http://, https://, nfs:// schemes the
operator may have pasted by mistake, plus trailing slashes.
API (crates/http-api/src/app.rs):
- api_nfs_add now returns a structured {error, stderr, hint} JSON body
on failure instead of plain text. UI renders the error in bold with
the hint as a dimmer second line.
UI (crates/webui/src/app.js):
- Storage tab's "Mount failed" banner now shows the raw error + hint on
two lines. Each persisted mount row also surfaces last_hint under
last_error.
Terminal (crates/http-api/src/terminal.rs):
- `nfs mount` command prints "hint: ..." on a follow-up line when the
manager returns one.
Tests:
- 8 new tests covering option string (incl. proto=tcp on v3, port=N for
non-default), minimal-options stripping, server normalization, and
hint translation for each well-known stderr pattern.
- All 150 tests pass; clippy -D warnings clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
9f694f7c79
commit
0afbe860e8
+29
-5
@@ -623,19 +623,41 @@
|
||||
const nfsRo = el('input', {type:'checkbox'}); nfsRo.checked = true;
|
||||
const addNfs = el('button', {onclick: async () => {
|
||||
if (!nfsServer.value || !nfsExport.value) {
|
||||
nfsMsg.textContent = 'Server and export are required.'; nfsMsg.className='msg err'; return;
|
||||
nfsMsg.replaceChildren(document.createTextNode('Server and export are required.'));
|
||||
nfsMsg.className='msg err'; return;
|
||||
}
|
||||
nfsMsg.textContent = 'Mounting…'; nfsMsg.className = 'msg';
|
||||
nfsMsg.replaceChildren(document.createTextNode('Mounting…'));
|
||||
nfsMsg.className = 'msg';
|
||||
const r = await postJSON('/api/nfs', {
|
||||
server: nfsServer.value, export: nfsExport.value,
|
||||
version: nfsVer.value, read_only: nfsRo.checked,
|
||||
});
|
||||
if (r.ok) {
|
||||
nfsMsg.textContent = 'Mounted.'; nfsMsg.className = 'msg ok';
|
||||
nfsMsg.replaceChildren(document.createTextNode('Mounted.'));
|
||||
nfsMsg.className = 'msg ok';
|
||||
render('storage');
|
||||
} else {
|
||||
const t = await r.text();
|
||||
nfsMsg.textContent = 'Mount failed: ' + t; nfsMsg.className = 'msg err';
|
||||
// v0.4.64: the API now returns a structured
|
||||
// {error, stderr, hint} JSON body so we can render the
|
||||
// mount failure and an actionable hint as two distinct lines
|
||||
// instead of one long unreadable string. The dominant field
|
||||
// failure mode — "mount.nfs: failed to apply fstab options" —
|
||||
// becomes useful when paired with its CAP_SYS_ADMIN hint.
|
||||
let body = null;
|
||||
let raw = null;
|
||||
try { body = await r.clone().json(); }
|
||||
catch (_) { raw = await r.text().catch(()=> 'mount failed'); }
|
||||
const msg = body && body.error ? body.error : (raw || 'mount failed');
|
||||
const hint = body && body.hint;
|
||||
const parts = [el('div', {}, [
|
||||
el('strong', {}, 'Mount failed: '),
|
||||
document.createTextNode(msg),
|
||||
])];
|
||||
if (hint) {
|
||||
parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
|
||||
}
|
||||
nfsMsg.replaceChildren(...parts);
|
||||
nfsMsg.className = 'msg err';
|
||||
}
|
||||
}}, 'Mount share');
|
||||
|
||||
@@ -648,6 +670,8 @@
|
||||
(m.read_only ? 'read-only' : 'read-write') + ' · ' +
|
||||
(m.mounted ? m.iso_count + ' isos' : 'not mounted')),
|
||||
m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
|
||||
// v0.4.64: actionable hint paired with the raw error.
|
||||
m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
|
||||
]),
|
||||
el('button', {class:'ghost', onclick: async () => {
|
||||
const r = await postJSON('/api/nfs/' + encodeURIComponent(m.id) + '/scan', {});
|
||||
|
||||
Reference in New Issue
Block a user