v0.4.65: Local directory ISO source (bind-mount workaround for Unraid)
Field report: even with CAP_SYS_ADMIN and full --privileged, NFS mounts
inside the OpenPXE container fail on Unraid with the same
"failed to apply fstab options" error v0.4.64 added diagnostics for.
The root cause is the host kernel: Unraid's base kernel ships without
the nfs/nfsv4 client modules loaded. Capabilities are necessary but
not sufficient; the modules have to be present on the host kernel for
in-container mount(2) to do anything. No container-side change can
fix that.
This is exactly the case every other PXE/imaging tool sidesteps
(Bootimus uses SMB; iVentoy, FOG, MAAS, Cobbler all rely on the host
to mount network storage and bind-mount the path into the imaging
service). v0.4.65 brings OpenPXE in line with that pattern.
What's new:
* `IsoSource::LocalDir { dir_id, relative_path }` — third source kind
alongside `Local` (uploaded) and `Nfs` (in-container mount).
* `LocalDirManager` (crates/iso-store/src/local_dir.rs) — registers
bind-mounted directories, validates them (absolute path, exists, is
a directory, readable), scans for *.iso files, registers them with
IsoStore. Persisted to <work_dir>/local_dirs.json so the relationship
survives restarts.
* `NfsHostCaps::detect()` — pure read of /proc/filesystems on startup.
Surfaced via GET /api/nfs/capabilities and used by the Storage tab to
show a prominent red banner above the NFS form when in-container
mounts cannot possibly work, pointing the operator at the Local
Directories card as the recommended path.
* Four new API routes:
GET /api/nfs/capabilities
GET /api/local-dirs
POST /api/local-dirs { path, label? }
DELETE /api/local-dirs/:id
POST /api/local-dirs/:id/scan
UI changes (crates/webui/src/app.js):
* Storage tab: new "Local directories" card under the NFS card with
the bind-mount form, an explainer paragraph (with the Docker
`-v /mnt/user/isos:/mnt/external-isos` command), and the list of
registered directories with rescan + remove actions.
* When NFS host caps are unavailable, the NFS card sprouts a red
banner explaining what's wrong and pointing at the local-dir
workaround. The card sub-header also flips to "N registered ·
recommended on this host".
* ISO table: new "dir:<id>" source badge; on-disk ISOs show "on disk"
in the actions column instead of a delete button (same pattern as
NFS — OpenPXE doesn't own those bytes).
* API reference table picks up the four new endpoints + a hint about
the new `port` field on NFS add.
Tests (+12, total 162):
* iso-store: 7 local_dir unit tests covering relative-path rejection,
missing path, non-directory file, empty-directory success, default
label, idempotent re-add, remove + iso-path-resolution clear.
* iso-store: 1 nfs unit test confirming NfsHostCaps::detect() never
panics and the boolean accessors are consistent.
* http-api: 4 integration tests covering /api/nfs/capabilities,
/api/local-dirs list/add/remove + relative-path 400.
`cargo clippy --workspace --all-targets -- -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
0afbe860e8
commit
761489761c
@@ -35,7 +35,7 @@ use openpxe_core::{
|
||||
MAX_LOGO_BYTES,
|
||||
};
|
||||
use openpxe_ipxe_assets::asset_bytes;
|
||||
use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest};
|
||||
use openpxe_iso_store::{IsoCategory, IsoMeta, LocalDirAddRequest, NfsAddRequest};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::net::SocketAddr;
|
||||
@@ -136,6 +136,13 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/api/nfs", get(api_nfs_list).post(api_nfs_add))
|
||||
.route("/api/nfs/:id", delete(api_nfs_remove))
|
||||
.route("/api/nfs/:id/scan", post(api_nfs_scan))
|
||||
// v0.4.65: host-kernel NFS capability probe + bind-mounted
|
||||
// local-directory source (works on Unraid / restricted-SCC
|
||||
// hosts that can't kernel-mount NFS inside the container).
|
||||
.route("/api/nfs/capabilities", get(api_nfs_capabilities))
|
||||
.route("/api/local-dirs", get(api_local_dirs_list).post(api_local_dirs_add))
|
||||
.route("/api/local-dirs/:id", delete(api_local_dirs_remove))
|
||||
.route("/api/local-dirs/:id/scan", post(api_local_dirs_scan))
|
||||
// Phase 4: Network info (read-only) + DNS edit.
|
||||
.route("/api/network", get(api_network).put(api_network_put))
|
||||
// Phase 4: live-log stream + recent buffer for the Terminal tab.
|
||||
@@ -1032,11 +1039,26 @@ async fn api_docs() -> Json<serde_json::Value> {
|
||||
{"method": "GET", "path": "/api/nfs",
|
||||
"summary": "List configured NFS shares with mount state and iso counts."},
|
||||
{"method": "POST", "path": "/api/nfs",
|
||||
"summary": "Mount an NFS share. Body: { server, export, version, read_only }."},
|
||||
"summary": "Mount an NFS share. Body: { server, export, version, read_only, port? }."},
|
||||
{"method": "DELETE", "path": "/api/nfs/:id",
|
||||
"summary": "Unmount a share and drop its entries from the ISO store."},
|
||||
{"method": "POST", "path": "/api/nfs/:id/scan",
|
||||
"summary": "Re-walk a mounted share for ISOs."},
|
||||
{"method": "GET", "path": "/api/nfs/capabilities",
|
||||
"summary": "Host-kernel NFS client support snapshot from /proc/filesystems."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Local directories (v0.4.65)",
|
||||
"endpoints": [
|
||||
{"method": "GET", "path": "/api/local-dirs",
|
||||
"summary": "List bind-mounted host directories registered as ISO sources."},
|
||||
{"method": "POST", "path": "/api/local-dirs",
|
||||
"summary": "Register a bind-mounted directory. Body: { path, label? }."},
|
||||
{"method": "DELETE", "path": "/api/local-dirs/:id",
|
||||
"summary": "Unregister a directory and drop its entries from the ISO store."},
|
||||
{"method": "POST", "path": "/api/local-dirs/:id/scan",
|
||||
"summary": "Re-walk a registered directory for ISOs."},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1736,6 +1758,62 @@ async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<Stri
|
||||
}
|
||||
}
|
||||
|
||||
// ─── v0.4.65: host-kernel NFS capability probe ─────────────────────────────
|
||||
|
||||
async fn api_nfs_capabilities(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
// The host caps are snapshotted at startup (pure read of
|
||||
// /proc/filesystems) so this handler is just a JSON projection.
|
||||
// The Storage tab uses `available` to decide whether to show the
|
||||
// "your kernel doesn't have NFS client support" banner above the
|
||||
// NFS form.
|
||||
let c = &state.nfs_host_caps;
|
||||
Json(json!({
|
||||
"available": c.available,
|
||||
"has_nfs3": c.has_nfs3,
|
||||
"has_nfs4": c.has_nfs4,
|
||||
"detail": c.detail,
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── v0.4.65: bind-mounted local directories ───────────────────────────────
|
||||
|
||||
async fn api_local_dirs_list(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
Json(json!({ "directories": state.local_dirs.list() }))
|
||||
}
|
||||
|
||||
async fn api_local_dirs_add(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<LocalDirAddRequest>,
|
||||
) -> Response {
|
||||
match state.local_dirs.add(req).await {
|
||||
Ok(d) => (StatusCode::CREATED, Json(d)).into_response(),
|
||||
// The manager's errors are always operator-actionable
|
||||
// (relative path / missing path / not-a-directory), so we
|
||||
// surface them verbatim as 400s.
|
||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_local_dirs_remove(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.local_dirs.remove(&id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_local_dirs_scan(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
match state.local_dirs.rescan(&id).await {
|
||||
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Network info API ──────────────────────────────────────────────────────
|
||||
|
||||
async fn api_network(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
|
||||
Reference in New Issue
Block a user