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
@@ -14,7 +14,7 @@ use axum::body::Body;
|
||||
use axum::http::{header, Request, StatusCode};
|
||||
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
|
||||
use openpxe_http_api::{build_router, AppState};
|
||||
use openpxe_iso_store::{IsoStore, NfsManager};
|
||||
use openpxe_iso_store::{IsoStore, LocalDirManager, NfsHostCaps, NfsManager};
|
||||
use tempfile::tempdir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -96,6 +96,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
let settings = SettingsStore::load_or_default(dir.path());
|
||||
let nfs = NfsManager::new(dir.path(), iso_store.clone());
|
||||
iso_store.set_nfs_root(nfs.mount_root());
|
||||
let local_dirs = LocalDirManager::new(dir.path(), iso_store.clone());
|
||||
let nfs_host_caps = NfsHostCaps::detect();
|
||||
let log_bus = LogBus::new(64);
|
||||
let hosts = HostBindings::load_or_default(dir.path());
|
||||
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
|
||||
@@ -118,6 +120,8 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
metrics,
|
||||
smb: None,
|
||||
nfs,
|
||||
local_dirs,
|
||||
nfs_host_caps,
|
||||
uploads: openpxe_http_api::uploads::UploadSessions::default(),
|
||||
log_bus,
|
||||
started_at: time::OffsetDateTime::now_utc(),
|
||||
@@ -495,6 +499,96 @@ async fn nfs_list_starts_empty() {
|
||||
assert_eq!(v["mounts"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ── v0.4.65: host kernel caps + local directories ───────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn nfs_capabilities_endpoint_returns_a_snapshot() {
|
||||
// The endpoint must always answer 200 with the shape the UI
|
||||
// expects, regardless of what `/proc/filesystems` actually says on
|
||||
// the test host. (CI runs both with and without NFS modules.)
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = get(&app, "/api/nfs/capabilities").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert!(v["available"].is_boolean());
|
||||
assert!(v["has_nfs3"].is_boolean());
|
||||
assert!(v["has_nfs4"].is_boolean());
|
||||
assert!(v["detail"].is_string());
|
||||
// Consistency: availability is true iff at least one of the
|
||||
// booleans is true.
|
||||
let derived = v["has_nfs3"].as_bool().unwrap() || v["has_nfs4"].as_bool().unwrap();
|
||||
assert_eq!(v["available"].as_bool().unwrap(), derived);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_dirs_list_starts_empty() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = get(&app, "/api/local-dirs").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["directories"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_dirs_add_rejects_relative_path() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, b) = post_json(
|
||||
&app,
|
||||
"/api/local-dirs",
|
||||
r#"{"path":"relative/path"}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
let msg = String::from_utf8_lossy(&b);
|
||||
assert!(
|
||||
msg.to_lowercase().contains("absolute"),
|
||||
"expected absolute-path hint, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_dirs_add_then_remove_round_trip() {
|
||||
let (state, work_dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
// Use a fresh subdirectory of the test's work_dir as the bind-
|
||||
// mount stand-in. It exists, is readable, contains no ISOs.
|
||||
let dir = work_dir.path().join("external-isos");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let body = format!(
|
||||
r#"{{"path":"{}","label":"my-isos"}}"#,
|
||||
dir.display()
|
||||
);
|
||||
let (s, b) = post_json(&app, "/api/local-dirs", &body).await;
|
||||
assert_eq!(s, StatusCode::CREATED, "add: {}", String::from_utf8_lossy(&b));
|
||||
let added: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
let id = added["id"].as_str().unwrap().to_string();
|
||||
assert_eq!(added["label"], "my-isos");
|
||||
assert_eq!(added["iso_count"], 0);
|
||||
|
||||
// List should now show one row.
|
||||
let (s, b) = get(&app, "/api/local-dirs").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["directories"].as_array().unwrap().len(), 1);
|
||||
|
||||
// Remove (DELETE) clears the row.
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(format!("/api/local-dirs/{id}"))
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let (s, b) = get(&app, "/api/local-dirs").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!(v["directories"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_help_and_status_round_trip() {
|
||||
let (state, _dir) = build_state().await;
|
||||
|
||||
Reference in New Issue
Block a user