From 07e7c1869819cc9f823569ba95ed4e58f44ea902 Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Thu, 28 May 2026 10:47:17 -0400 Subject: [PATCH] Revert "v0.4.65: Local directory ISO source (bind-mount workaround for Unraid)" This reverts commit 72a2089c98871c84a2d2063c1125d030c7882602. --- Cargo.lock | 16 +- Cargo.toml | 2 +- crates/http-api/src/app.rs | 82 +---- crates/http-api/src/state.rs | 13 +- crates/http-api/src/terminal.rs | 2 - crates/http-api/tests/full_flow.rs | 96 +----- crates/iso-store/src/lib.rs | 4 +- crates/iso-store/src/local_dir.rs | 515 ----------------------------- crates/iso-store/src/nfs.rs | 78 ----- crates/iso-store/src/store.rs | 61 +--- crates/openpxe/src/main.rs | 37 +-- crates/webui/src/app.js | 150 +-------- 12 files changed, 29 insertions(+), 1027 deletions(-) delete mode 100644 crates/iso-store/src/local_dir.rs diff --git a/Cargo.lock b/Cargo.lock index 1dfb95e..9139e2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1140,7 +1140,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openpxe" -version = "0.4.65" +version = "0.4.64" dependencies = [ "anyhow", "axum", @@ -1162,7 +1162,7 @@ dependencies = [ [[package]] name = "openpxe-core" -version = "0.4.65" +version = "0.4.64" dependencies = [ "anyhow", "bcrypt", @@ -1181,7 +1181,7 @@ dependencies = [ [[package]] name = "openpxe-dhcp-proxy" -version = "0.4.65" +version = "0.4.64" dependencies = [ "anyhow", "bytes", @@ -1195,7 +1195,7 @@ dependencies = [ [[package]] name = "openpxe-http-api" -version = "0.4.65" +version = "0.4.64" dependencies = [ "anyhow", "axum", @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "openpxe-ipxe-assets" -version = "0.4.65" +version = "0.4.64" dependencies = [ "openpxe-core", "rust-embed", @@ -1236,7 +1236,7 @@ dependencies = [ [[package]] name = "openpxe-iso-store" -version = "0.4.65" +version = "0.4.64" dependencies = [ "anyhow", "bcrypt", @@ -1260,7 +1260,7 @@ dependencies = [ [[package]] name = "openpxe-tftp" -version = "0.4.65" +version = "0.4.64" dependencies = [ "anyhow", "bytes", @@ -1274,7 +1274,7 @@ dependencies = [ [[package]] name = "openpxe-webui" -version = "0.4.65" +version = "0.4.64" [[package]] name = "parking_lot" diff --git a/Cargo.toml b/Cargo.toml index c9d2c00..3538ceb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ ] [workspace.package] -version = "0.4.65" +version = "0.4.64" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index aafef4c..98044d3 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -35,7 +35,7 @@ use openpxe_core::{ MAX_LOGO_BYTES, }; use openpxe_ipxe_assets::asset_bytes; -use openpxe_iso_store::{IsoCategory, IsoMeta, LocalDirAddRequest, NfsAddRequest}; +use openpxe_iso_store::{IsoCategory, IsoMeta, NfsAddRequest}; use serde::Deserialize; use serde_json::json; use std::net::SocketAddr; @@ -136,13 +136,6 @@ 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. @@ -1039,26 +1032,11 @@ async fn api_docs() -> Json { {"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, port? }."}, + "summary": "Mount an NFS share. Body: { server, export, version, read_only }."}, {"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."}, ], }, { @@ -1758,62 +1736,6 @@ async fn api_nfs_scan(State(state): State, AxumPath(id): AxumPath) -> Json { - // 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) -> Json { - Json(json!({ "directories": state.local_dirs.list() })) -} - -async fn api_local_dirs_add( - State(state): State, - Json(req): Json, -) -> 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, - AxumPath(id): AxumPath, -) -> 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, - AxumPath(id): AxumPath, -) -> 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) -> Json { diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs index a2cd889..aeb0da8 100644 --- a/crates/http-api/src/state.rs +++ b/crates/http-api/src/state.rs @@ -4,7 +4,7 @@ use openpxe_core::{ AdminStore, BootLog, BrandingStore, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore, SsoStore, }; -use openpxe_iso_store::{IsoStore, LocalDirManager, NfsHostCaps, NfsManager, SmbManager}; +use openpxe_iso_store::{IsoStore, NfsManager, SmbManager}; use std::sync::Arc; use time::OffsetDateTime; @@ -49,17 +49,6 @@ pub struct AppState { /// available in the runtime image. Surfaces errors per-mount rather /// than failing the global state. pub nfs: NfsManager, - /// v0.4.65: bind-mounted host directories surfaced as ISO sources. - /// The container-friendly workaround for hosts (Unraid is the - /// dominant case) whose kernel lacks NFS client modules. Always - /// present; the operator opts in by registering paths from the - /// Storage tab. - pub local_dirs: LocalDirManager, - /// v0.4.65: host-kernel capability snapshot (from - /// `/proc/filesystems`). Snapshotted once at startup; the Storage - /// tab uses `available` to decide whether to show a "your kernel - /// doesn't have NFS client support" banner above the NFS form. - pub nfs_host_caps: NfsHostCaps, /// Browser chunked upload state. Multipart uploads still go straight /// through `IsoStore`, but the UI uses sessions so large ISO transfers /// can show deterministic progress and leave visible partial files. diff --git a/crates/http-api/src/terminal.rs b/crates/http-api/src/terminal.rs index 849a6b8..8cd9aeb 100644 --- a/crates/http-api/src/terminal.rs +++ b/crates/http-api/src/terminal.rs @@ -159,8 +159,6 @@ fn isos_text(s: &AppState) -> String { let src = match i.source { openpxe_iso_store::IsoSource::Local => "local".to_string(), openpxe_iso_store::IsoSource::Nfs { mount_id, .. } => format!("nfs:{mount_id}"), - // v0.4.65: bind-mounted host directory. - openpxe_iso_store::IsoSource::LocalDir { dir_id, .. } => format!("dir:{dir_id}"), }; let _ = writeln!( out, diff --git a/crates/http-api/tests/full_flow.rs b/crates/http-api/tests/full_flow.rs index e79314a..5780f6c 100644 --- a/crates/http-api/tests/full_flow.rs +++ b/crates/http-api/tests/full_flow.rs @@ -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, LocalDirManager, NfsHostCaps, NfsManager}; +use openpxe_iso_store::{IsoStore, NfsManager}; use tempfile::tempdir; use tower::ServiceExt; @@ -96,8 +96,6 @@ 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()); @@ -120,8 +118,6 @@ 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(), @@ -499,96 +495,6 @@ 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; diff --git a/crates/iso-store/src/lib.rs b/crates/iso-store/src/lib.rs index cdd113b..ebaabe6 100644 --- a/crates/iso-store/src/lib.rs +++ b/crates/iso-store/src/lib.rs @@ -18,7 +18,6 @@ pub mod entry; pub mod introspect; -pub mod local_dir; pub mod nfs; pub mod pxe_logo; pub mod smb; @@ -27,8 +26,7 @@ pub mod windows; pub use entry::{BootEntry, BootKind, KernelArgs}; pub use introspect::{DistroFamily, IntrospectionReport}; -pub use local_dir::{LocalDirAddRequest, LocalDirManager, LocalDirSpec}; -pub use nfs::{NfsAddRequest, NfsHostCaps, NfsManager, NfsMount, NfsVersion}; +pub use nfs::{NfsAddRequest, NfsManager, NfsMount, NfsVersion}; pub use smb::{extract_windows_iso, SmbManager, SmbState}; pub use store::{ generate_boot_entries_for, slugify_str, IsoCategory, IsoMeta, IsoSource, IsoStore, diff --git a/crates/iso-store/src/local_dir.rs b/crates/iso-store/src/local_dir.rs deleted file mode 100644 index 37b6b9f..0000000 --- a/crates/iso-store/src/local_dir.rs +++ /dev/null @@ -1,515 +0,0 @@ -//! Local-directory ISO source. -//! -//! v0.4.65: operators on container hosts that lack kernel NFS client -//! modules (Unraid is the dominant case) can't successfully `mount -t -//! nfs ...` inside the container regardless of `CAP_SYS_ADMIN` — -//! mount(2) returns EOPNOTSUPP or EINVAL, and nfs-utils translates that -//! into the deeply unhelpful `mount.nfs: failed to apply fstab options` -//! message we wired diagnostics for in v0.4.64. -//! -//! The pragmatic workaround used by Bootimus, iVentoy, FOG, MAAS, and -//! pretty much every other PXE/imaging tool is: don't try to mount -//! network storage inside the PXE server. Instead the *host* mounts the -//! remote share (Unraid's Unassigned Devices plugin, Synology's File -//! Station, mount.nfs at the OS level, …) and *bind-mounts* the -//! resulting local path into the container. The PXE server then reads -//! ISOs from a regular directory on disk — no protocol work, no -//! capabilities, no kernel module dependency. -//! -//! `LocalDirManager` is the in-container side of that workflow. The -//! operator pastes the path of a bind-mounted directory; we validate it -//! exists and is readable, walk it for `*.iso` files, register each one -//! with the `IsoStore` as an `IsoSource::LocalDir` entry, and persist -//! the spec to `/local_dirs.json` so the relationship survives -//! restarts. -//! -//! The intentional differences from `NfsManager`: -//! -//! - **No mount step.** The path must already be a directory; we never -//! shell out to `mount`. Any failure mode is a clean operator error -//! surfaced in the UI. -//! - **Path is the identity.** We slugify the absolute path so the -//! operator can re-add the same directory idempotently. (Two -//! directories with the same trailing component get distinct ids -//! because the full path goes into the slug.) -//! - **No password complexity.** Bytes live outside OpenPXE's control; -//! we don't try to gate them with the per-ISO password feature. -//! -//! Re-scans on startup are best-effort: if the bind mount was removed -//! before the container came back up, the directory will fail -//! validation, we log a warning, and surface the error in the UI row. -//! The spec itself stays persisted so a re-introduced bind mount picks -//! up where it left off. - -use crate::introspect::{introspect, IntrospectionReport}; -use crate::store::{generate_boot_entries_for, slugify_str, IsoSource, IsoStore}; -use openpxe_core::{Error, Result}; -use parking_lot::Mutex; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use time::OffsetDateTime; - -/// One operator-registered directory. The id is a slug of the absolute -/// path so an operator who pastes the same path twice gets idempotent -/// behaviour rather than a duplicate row. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LocalDirSpec { - pub id: String, - /// Absolute path inside the container. Validated to be a directory - /// on every scan; never traversed for shell-special characters - /// because we only ever read from it (no command construction). - pub path: PathBuf, - /// Optional friendly name for the UI row. Defaults to the trailing - /// path component when not provided. - pub label: String, - /// Most recent error encountered scanning this directory, or `None` - /// on success. Mirrors the `last_error` / `last_hint` pair on - /// `NfsMount` so the UI can render local-dir issues the same way it - /// renders NFS-mount issues. - pub last_error: Option, - pub last_hint: Option, - #[serde(with = "time::serde::rfc3339::option")] - pub last_scan: Option, - /// Number of `*.iso` files registered from this directory on the - /// most recent scan. - pub iso_count: u32, -} - -/// Submission from the UI / API. -#[derive(Debug, Clone, Deserialize)] -pub struct LocalDirAddRequest { - pub path: String, - #[serde(default)] - pub label: Option, -} - -#[derive(Debug, Default)] -struct Inner { - dirs: HashMap, -} - -/// Manages bind-mounted host directories and surfaces their ISOs. -/// -/// Cheap to clone — internal state is behind `Arc>`. -#[derive(Debug, Clone)] -pub struct LocalDirManager { - state_path: Arc, - inner: Arc>, - iso_store: IsoStore, -} - -impl LocalDirManager { - /// Construct a manager that persists state to - /// `/local_dirs.json`. Mount points are not under our - /// control — the bind mount is supplied by the operator's container - /// runtime — so unlike `NfsManager` we don't take a `work_dir` for - /// our own filesystem state besides this single JSON file. - #[must_use] - pub fn new(work_dir: &Path, iso_store: IsoStore) -> Self { - Self { - state_path: Arc::new(work_dir.join("local_dirs.json")), - inner: Arc::new(Mutex::new(Inner::default())), - iso_store, - } - } - - /// Load persisted state and rescan every directory. Errors per - /// directory are logged and surfaced on the spec; the call itself - /// never fails — startup must not block on a single missing - /// bind mount. - pub async fn load_and_rescan(&self) -> Result<()> { - let specs = match tokio::fs::read_to_string(self.state_path.as_path()).await { - Ok(text) => serde_json::from_str::>(&text).unwrap_or_default(), - Err(_) => Vec::new(), - }; - for mut spec in specs { - spec.last_error = None; - spec.last_hint = None; - self.inner.lock().dirs.insert(spec.id.clone(), spec.clone()); - // Register the mapping early so even a failed rescan keeps - // any path resolution wired up correctly. - self.iso_store - .register_local_dir(spec.id.clone(), spec.path.clone()); - if let Err(e) = self.rescan_inner(&spec.id).await { - tracing::warn!( - target: "openpxe::local_dir", - id = %spec.id, path = %spec.path.display(), - "rescan on startup failed: {e}" - ); - } - } - Ok(()) - } - - /// Add or refresh a directory. Validates the path is absolute, - /// exists, and is a directory. On success the directory's ISOs are - /// registered with the `IsoStore` and the spec is persisted. - pub async fn add(&self, req: LocalDirAddRequest) -> Result { - let path = PathBuf::from(req.path.trim()); - if path.as_os_str().is_empty() { - return Err(Error::Invalid("path is required".into())); - } - if !path.is_absolute() { - return Err(Error::Invalid(format!( - "path must be absolute (got '{}'); bind-mount the source on \ - the host then enter the path inside the container", - path.display() - ))); - } - let meta = match tokio::fs::metadata(&path).await { - Ok(m) => m, - Err(e) => { - return Err(Error::Invalid(format!( - "path '{}' is not accessible: {e}. Verify the bind mount \ - exists inside the container", - path.display() - ))); - } - }; - if !meta.is_dir() { - return Err(Error::Invalid(format!( - "path '{}' exists but is not a directory", - path.display() - ))); - } - - let id = slugify_str(&path.display().to_string()); - let label = req - .label - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .or_else(|| { - path.file_name() - .and_then(|s| s.to_str()) - .map(str::to_string) - }) - .unwrap_or_else(|| id.clone()); - - let spec = LocalDirSpec { - id: id.clone(), - path: path.clone(), - label, - last_error: None, - last_hint: None, - last_scan: None, - iso_count: 0, - }; - self.inner.lock().dirs.insert(id.clone(), spec); - self.iso_store.register_local_dir(id.clone(), path); - self.persist_locked(); - self.rescan_inner(&id).await?; - Ok(self.get(&id).expect("just inserted")) - } - - /// Remove a directory: drops every ISO sourced from it and forgets - /// the spec. Idempotent. - /// - /// Kept `async` for symmetry with `NfsManager::remove` (which does - /// shell out to `umount`) so the HTTP handlers can treat both - /// managers identically. The body is sync today; a future audit - /// log or scrub-on-remove I/O step would plug in here without a - /// signature change. - #[allow(clippy::unused_async)] - pub async fn remove(&self, id: &str) -> Result<()> { - self.inner.lock().dirs.remove(id); - self.iso_store.unregister_local_dir(id); - self.iso_store.drop_local_dir_source(id); - self.persist_locked(); - Ok(()) - } - - /// Manually rescan a directory — picks up newly-added ISOs without - /// removing & re-adding the directory. - pub async fn rescan(&self, id: &str) -> Result { - self.rescan_inner(id).await - } - - /// Snapshot of every registered directory, sorted by id for stable - /// UI rendering. - #[must_use] - pub fn list(&self) -> Vec { - let g = self.inner.lock(); - let mut v: Vec<_> = g.dirs.values().cloned().collect(); - v.sort_by(|a, b| a.id.cmp(&b.id)); - v - } - - /// Look up one spec by id. - #[must_use] - pub fn get(&self, id: &str) -> Option { - self.inner.lock().dirs.get(id).cloned() - } - - // ── internals ───────────────────────────────────────────────────── - - async fn rescan_inner(&self, id: &str) -> Result { - let spec = self - .get(id) - .ok_or_else(|| Error::Invalid(format!("no such directory '{id}'")))?; - let now = OffsetDateTime::now_utc(); - - // Drop prior entries first so a deleted file disappears from the - // store on the next rescan. Mirrors NfsManager::scan_and_register. - self.iso_store.drop_local_dir_source(id); - - let mut walker = match tokio::fs::read_dir(&spec.path).await { - Ok(w) => w, - Err(e) => { - let err = format!("cannot read directory: {e}"); - let hint = Some( - "Verify the bind mount still exists inside the container \ - and that the openpxe user can read it (uid 10001)." - .to_string(), - ); - self.update_status(id, 0, Some(err.clone()), hint, now); - return Err(Error::Invalid(err)); - } - }; - - let mut count = 0u32; - loop { - let entry = match walker.next_entry().await { - Ok(Some(e)) => e, - Ok(None) => break, - Err(e) => { - let err = format!("read_dir iteration failed: {e}"); - self.update_status(id, count, Some(err.clone()), None, now); - return Err(Error::Invalid(err)); - } - }; - let p = entry.path(); - if p.extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase) - .as_deref() - != Some("iso") - { - continue; - } - let filename = match p.file_name().and_then(|s| s.to_str()) { - Some(f) => f.to_string(), - None => continue, - }; - let size = match tokio::fs::metadata(&p).await { - Ok(m) => m.len(), - Err(e) => { - tracing::warn!( - target: "openpxe::local_dir", - id = %id, file = %filename, "stat failed: {e}" - ); - continue; - } - }; - let p_owned = p.clone(); - let report: IntrospectionReport = - match tokio::task::spawn_blocking(move || introspect(&p_owned)).await { - Ok(r) => r, - Err(e) => { - tracing::warn!( - target: "openpxe::local_dir", - id = %id, file = %filename, - "introspection task panicked: {e}" - ); - continue; - } - }; - let iso_id = format!("local-{id}-{}", slugify_str(&filename)); - let boot_entries = generate_boot_entries_for(&iso_id, &filename, &report); - let source = IsoSource::LocalDir { - dir_id: id.to_string(), - relative_path: filename.clone(), - }; - self.iso_store.register_external( - iso_id, - filename, - size, - report, - boot_entries, - source, - ); - count += 1; - } - - self.update_status(id, count, None, None, now); - tracing::info!( - target: "openpxe::local_dir", - id = %id, path = %spec.path.display(), iso_count = count, - "local directory rescanned" - ); - Ok(count) - } - - fn update_status( - &self, - id: &str, - iso_count: u32, - err: Option, - hint: Option, - ts: OffsetDateTime, - ) { - if let Some(s) = self.inner.lock().dirs.get_mut(id) { - s.iso_count = iso_count; - s.last_error = err; - s.last_hint = hint; - s.last_scan = Some(ts); - } - self.persist_locked(); - } - - /// Atomically replace the on-disk JSON with the current state. - /// Errors are logged, never propagated — settings live in memory - /// authoritatively, matching every other store in this crate. - fn persist_locked(&self) { - let dirs: Vec = self.inner.lock().dirs.values().cloned().collect(); - let path = self.state_path.as_path(); - let tmp = path.with_extension("json.tmp"); - let body = match serde_json::to_vec_pretty(&dirs) { - Ok(b) => b, - Err(e) => { - tracing::warn!(target: "openpxe::local_dir", "serialize: {e}"); - return; - } - }; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Err(e) = std::fs::write(&tmp, body) { - tracing::warn!(target: "openpxe::local_dir", "write tmp: {e}"); - return; - } - if let Err(e) = std::fs::rename(&tmp, path) { - tracing::warn!(target: "openpxe::local_dir", "rename: {e}"); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - fn make_manager() -> (LocalDirManager, IsoStore, tempfile::TempDir) { - let tmp = tempdir().unwrap(); - let iso = IsoStore::new(tmp.path().join("isos")); - let m = LocalDirManager::new(tmp.path(), iso.clone()); - (m, iso, tmp) - } - - #[tokio::test] - async fn rejects_relative_paths() { - let (m, _iso, _tmp) = make_manager(); - let err = m - .add(LocalDirAddRequest { - path: "relative/path".into(), - label: None, - }) - .await - .unwrap_err(); - assert!(format!("{err}").to_lowercase().contains("absolute")); - } - - #[tokio::test] - async fn rejects_missing_paths() { - let (m, _iso, _tmp) = make_manager(); - let err = m - .add(LocalDirAddRequest { - path: "/nonexistent/path/should/not/be/here".into(), - label: None, - }) - .await - .unwrap_err(); - let s = format!("{err}").to_lowercase(); - assert!(s.contains("not accessible") || s.contains("bind mount")); - } - - #[tokio::test] - async fn rejects_files_that_are_not_directories() { - let (m, _iso, tmp) = make_manager(); - let file = tmp.path().join("a_file.txt"); - std::fs::write(&file, b"hi").unwrap(); - let err = m - .add(LocalDirAddRequest { - path: file.to_string_lossy().into_owned(), - label: None, - }) - .await - .unwrap_err(); - assert!(format!("{err}").to_lowercase().contains("not a directory")); - } - - #[tokio::test] - async fn empty_directory_registers_with_zero_isos() { - let (m, _iso, tmp) = make_manager(); - let empty = tmp.path().join("empty"); - std::fs::create_dir(&empty).unwrap(); - let spec = m - .add(LocalDirAddRequest { - path: empty.to_string_lossy().into_owned(), - label: Some("My empty dir".into()), - }) - .await - .unwrap(); - assert_eq!(spec.iso_count, 0); - assert_eq!(spec.label, "My empty dir"); - assert!(spec.last_error.is_none()); - } - - #[tokio::test] - async fn label_defaults_to_trailing_path_component() { - let (m, _iso, tmp) = make_manager(); - let d = tmp.path().join("my-isos"); - std::fs::create_dir(&d).unwrap(); - let spec = m - .add(LocalDirAddRequest { - path: d.to_string_lossy().into_owned(), - label: None, - }) - .await - .unwrap(); - assert_eq!(spec.label, "my-isos"); - } - - #[tokio::test] - async fn re_adding_same_path_is_idempotent() { - let (mgr, _iso, tmp) = make_manager(); - let dir = tmp.path().join("re-add"); - std::fs::create_dir(&dir).unwrap(); - let path = dir.to_string_lossy().into_owned(); - let first = mgr - .add(LocalDirAddRequest { - path: path.clone(), - label: None, - }) - .await - .unwrap(); - let second = mgr - .add(LocalDirAddRequest { path, label: None }) - .await - .unwrap(); - assert_eq!(first.id, second.id); - assert_eq!(mgr.list().len(), 1); - } - - #[tokio::test] - async fn remove_drops_the_directory_and_clears_iso_store_path() { - let (m, iso, tmp) = make_manager(); - let d = tmp.path().join("to-remove"); - std::fs::create_dir(&d).unwrap(); - let spec = m - .add(LocalDirAddRequest { - path: d.to_string_lossy().into_owned(), - label: None, - }) - .await - .unwrap(); - m.remove(&spec.id).await.unwrap(); - assert!(m.get(&spec.id).is_none()); - // After unregister, the IsoStore can no longer resolve a path - // through this dir_id — confirms the mapping was dropped. - assert!(iso - .iso_path_for(&format!("local-{}-anything", spec.id)) - .is_none()); - } -} diff --git a/crates/iso-store/src/nfs.rs b/crates/iso-store/src/nfs.rs index 5e7d9dc..eecf004 100644 --- a/crates/iso-store/src/nfs.rs +++ b/crates/iso-store/src/nfs.rs @@ -73,73 +73,6 @@ use tokio::process::Command; /// pre-flight probe and the option string assembly use the same value. const DEFAULT_NFS_PORT: u16 = 2049; -/// What the host kernel can do, from the container's point of view. -/// -/// v0.4.65: we read this once at startup so the Storage tab can show -/// a prominent banner when in-container NFS mounts simply cannot -/// succeed regardless of CAP_SYS_ADMIN — the dominant case being -/// Unraid, whose base kernel ships without the `nfs` / `nfsv4` client -/// modules loaded. There's nothing the operator can do from inside the -/// container in that scenario; the right answer is to mount the share -/// on the host and bind-mount the path into OpenPXE (see -/// `LocalDirManager`). -/// -/// `available` is `true` if `/proc/filesystems` contains *either* `nfs` -/// or `nfs4`. `raw` is the matched lines (or a short marker), retained -/// so we can show "what we actually saw" in the UI for support. -#[derive(Debug, Clone, Serialize)] -pub struct NfsHostCaps { - pub available: bool, - pub has_nfs3: bool, - pub has_nfs4: bool, - /// `/proc/filesystems` lines we matched, joined with `\n`. Empty - /// when nothing matched, `"unreadable"` if the probe itself failed. - pub detail: String, -} - -impl NfsHostCaps { - /// Probe `/proc/filesystems` for NFS client filesystem support. - /// Pure read, no side effects — safe to call repeatedly. Reads - /// synchronously because `/proc/filesystems` is virtual and never - /// blocks. - #[must_use] - pub fn detect() -> Self { - let Ok(raw) = std::fs::read_to_string("/proc/filesystems") else { - return Self { - available: false, - has_nfs3: false, - has_nfs4: false, - detail: "unreadable".into(), - }; - }; - // Each line is "nodev\tname" or "\tname". We tokenize on - // whitespace and check the last token. - let mut has_nfs3 = false; - let mut has_nfs4 = false; - let mut matched: Vec<&str> = Vec::new(); - for line in raw.lines() { - let name = line.split_whitespace().last().unwrap_or(""); - match name { - "nfs" | "nfs3" => { - has_nfs3 = true; - matched.push(line.trim()); - } - "nfs4" | "nfsv4" => { - has_nfs4 = true; - matched.push(line.trim()); - } - _ => {} - } - } - Self { - available: has_nfs3 || has_nfs4, - has_nfs3, - has_nfs4, - detail: matched.join("\n"), - } - } -} - /// How long to wait for a TCP connection to the NFS server before /// declaring it unreachable. Short enough that a wrong IP doesn't make /// the UI hang for half a minute; long enough that a slow appliance @@ -1048,15 +981,4 @@ mod tests { "mount.nfs: access denied" )); } - - #[test] - fn nfs_host_caps_detect_is_consistent() { - // The detect() probe should never panic and the boolean - // accessors should agree with each other. - let c = NfsHostCaps::detect(); - assert_eq!(c.available, c.has_nfs3 || c.has_nfs4); - // Whatever the result, `detail` is always a string we can - // present in the UI (possibly empty, possibly "unreadable"). - let _ = c.detail; - } } diff --git a/crates/iso-store/src/store.rs b/crates/iso-store/src/store.rs index 2c6c39f..20b2c3e 100644 --- a/crates/iso-store/src/store.rs +++ b/crates/iso-store/src/store.rs @@ -17,15 +17,8 @@ use tokio::io::AsyncWriteExt; /// /// The default is `Local` — uploaded ISOs sit in `/.iso`. /// `Nfs` entries point at a file inside a remote share that the -/// `NfsManager` is keeping mounted. `LocalDir` (v0.4.65) entries point -/// at a file inside an operator-registered directory that's -/// **bind-mounted into the container** from the host — the workaround -/// for environments (Unraid, OpenShift restricted SCC) where the host -/// kernel doesn't have NFS client modules loaded and in-container -/// mounts can't succeed regardless of CAP_SYS_ADMIN. -/// -/// We resolve every variant's on-disk path lazily in -/// [`IsoStore::iso_path_for`]. +/// `NfsManager` is keeping mounted. We resolve the on-disk path lazily +/// in [`IsoStore::iso_path_for`] using the `nfs_root` set at startup. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum IsoSource { @@ -36,13 +29,6 @@ pub enum IsoSource { /// Path relative to the mount point — typically just the filename. relative_path: String, }, - /// v0.4.65: operator-registered host directory bind-mounted into the - /// container. Path resolution looks up `dir_id` in the IsoStore's - /// `local_dir_roots` map, then joins `relative_path`. - LocalDir { - dir_id: String, - relative_path: String, - }, } /// Where the ISO lands in the PXE menu hierarchy. @@ -182,10 +168,6 @@ pub struct IsoStore { /// [`IsoStore::set_nfs_root`]; required for resolving any /// `IsoSource::Nfs` entry. nfs_root: Arc>>, - /// v0.4.65: map of operator-registered directory id → absolute path - /// inside the container. Used to resolve `IsoSource::LocalDir` - /// entries to a real on-disk path. Maintained by `LocalDirManager`. - local_dir_roots: Arc>>, inner: Arc>, } @@ -194,26 +176,10 @@ impl IsoStore { Self { iso_dir: Arc::new(iso_dir), nfs_root: Arc::new(RwLock::new(None)), - local_dir_roots: Arc::new(RwLock::new(HashMap::new())), inner: Arc::new(RwLock::new(Inner::default())), } } - /// v0.4.65: register the bind-mounted host path under `dir_id` so - /// `iso_path_for` can resolve `IsoSource::LocalDir` entries. Called - /// by `LocalDirManager` when a directory is added or reloaded from - /// persisted state. - pub fn register_local_dir(&self, dir_id: String, path: PathBuf) { - self.local_dir_roots.write().insert(dir_id, path); - } - - /// v0.4.65: drop the path mapping for `dir_id`. Existing - /// `IsoSource::LocalDir` entries referencing this id will start - /// resolving to `None` from `iso_path_for`. - pub fn unregister_local_dir(&self, dir_id: &str) { - self.local_dir_roots.write().remove(dir_id); - } - /// Tell the store where NFS mounts live. Without this set, /// `IsoSource::Nfs` entries cannot be resolved to a file path. pub fn set_nfs_root(&self, root: PathBuf) { @@ -331,19 +297,6 @@ impl IsoStore { let root = self.nfs_root.read().clone()?; root.join(mount_id).join(relative_path) } - // v0.4.65: bind-mounted host directory. We look the dir_id - // up in local_dir_roots which is maintained by - // LocalDirManager. If LocalDirManager hasn't loaded yet (or - // the operator removed the directory) the resolution - // returns None and the HTTP handler 404s, same as a missing - // NFS mount. - IsoSource::LocalDir { - dir_id, - relative_path, - } => { - let root = self.local_dir_roots.read().get(dir_id).cloned()?; - root.join(relative_path) - } }; if path.exists() { Some(path) @@ -409,16 +362,6 @@ impl IsoStore { ); } - /// v0.4.65: drop every entry that belongs to a registered local - /// directory. Used by `LocalDirManager` when an operator removes a - /// directory or before re-scanning to clean out stale entries. - pub fn drop_local_dir_source(&self, dir_id: &str) { - let mut g = self.inner.write(); - g.isos.retain( - |_, m| !matches!(&m.source, IsoSource::LocalDir { dir_id: did, .. } if did == dir_id), - ); - } - /// Set or clear an ISO's boot password. /// /// `Some("plaintext")` hashes via bcrypt (cost 10 — fast enough for diff --git a/crates/openpxe/src/main.rs b/crates/openpxe/src/main.rs index 654eb2f..a721973 100644 --- a/crates/openpxe/src/main.rs +++ b/crates/openpxe/src/main.rs @@ -9,7 +9,7 @@ use openpxe_core::{ }; use openpxe_dhcp_proxy::DhcpProxyServer; use openpxe_http_api::{build_router, AppState}; -use openpxe_iso_store::{IsoStore, LocalDirManager, NfsHostCaps, NfsManager, SmbManager}; +use openpxe_iso_store::{IsoStore, NfsManager, SmbManager}; use openpxe_tftp::TftpServer; use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; @@ -128,39 +128,6 @@ async fn main() -> anyhow::Result<()> { tracing::warn!(target: "openpxe::nfs", "could not reload NFS mounts: {e}"); } - // v0.4.65: snapshot host kernel capabilities so the Storage tab can - // warn the operator early when in-container NFS mounts cannot - // possibly succeed (Unraid is the dominant case — its base kernel - // ships without nfs/nfsv4 client modules loaded). Pure read of - // /proc/filesystems; no side effects. - let nfs_host_caps = NfsHostCaps::detect(); - if nfs_host_caps.available { - tracing::info!( - target: "openpxe::nfs", - v3 = nfs_host_caps.has_nfs3, v4 = nfs_host_caps.has_nfs4, - "host kernel NFS client support" - ); - } else { - tracing::warn!( - target: "openpxe::nfs", - detail = %nfs_host_caps.detail, - "host kernel lacks NFS client modules — in-container `mount -t nfs` will fail \ - regardless of CAP_SYS_ADMIN. Use Local Directories instead." - ); - } - - // v0.4.65: bind-mounted host directory manager. Provides the - // container-friendly ISO source that doesn't depend on kernel NFS - // support — the operator mounts the share on the host, bind-mounts - // the path into the container, and registers it here. - let local_dirs = LocalDirManager::new(&config.paths.work_dir, iso_store.clone()); - if let Err(e) = local_dirs.load_and_rescan().await { - tracing::warn!( - target: "openpxe::local_dir", - "could not reload local directories: {e}" - ); - } - // Sniff network details for the Network tab. None of these are // required for PXE to work — they're informational, surfaced in the // UI so an operator doesn't have to drop to a shell to find their @@ -186,8 +153,6 @@ async fn main() -> anyhow::Result<()> { metrics: metrics.clone(), smb: Some(smb.clone()), nfs: nfs.clone(), - local_dirs: local_dirs.clone(), - nfs_host_caps: nfs_host_caps.clone(), uploads: openpxe_http_api::uploads::UploadSessions::default(), log_bus: log_bus.clone(), started_at: time::OffsetDateTime::now_utc(), diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index e1b790c..3f0feea 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -342,21 +342,13 @@ }, storage: async () => { - // v0.4.65: also fetch host NFS caps and local-dir registrations. - // Caps tell us whether kernel mounts can possibly work; local - // dirs are the bind-mount workaround for hosts that can't. - const [isos, settings, nfsRes, disk, caps, localRes] = await Promise.all([ + const [isos, settings, nfsRes, disk] = await Promise.all([ getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'), getJSON('/api/storage/disk').catch(() => ({ total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?', })), - getJSON('/api/nfs/capabilities').catch(() => ({ - available: true, has_nfs3: true, has_nfs4: true, detail: '', - })), - getJSON('/api/local-dirs').catch(() => ({ directories: [] })), ]); const mounts = nfsRes.mounts || []; - const localDirs = localRes.directories || []; // ── Upload card ── const drop = el('div', {class:'drop', id:'drop'}, [ @@ -467,8 +459,6 @@ isos.forEach(i => { const b = bootability(i, settings); const isNfs = i.source && i.source.kind === 'nfs'; - // v0.4.65: third source kind — bind-mounted host directory. - const isLocalDir = i.source && i.source.kind === 'local_dir'; const protectedNow = !!i.password_hash; // The inline editor row is hidden by default; the Password @@ -588,9 +578,8 @@ ]), el('td', {class:'num'}, fmtBytes(i.size_bytes)), el('td', {}, - el('span', {class:'src-badge' + (isNfs ? ' nfs' : (isLocalDir ? ' nfs' : ''))}, - isNfs ? ('nfs:' + i.source.mount_id) - : (isLocalDir ? ('dir:' + i.source.dir_id) : 'local'))), + el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')}, + isNfs ? ('nfs:' + i.source.mount_id) : 'local')), el('td', {}, protectedNow ? el('span', {class:'tag accent'}, 'protected') @@ -602,17 +591,11 @@ }}, protectedNow ? 'Password ✎' : 'Set password'), isNfs ? el('span', {class:'tag', style:'opacity:.6'}, 'on NFS') - : (isLocalDir - // v0.4.65: bytes live in an operator-managed - // bind-mounted directory; OpenPXE shouldn't try to - // delete files it didn't create. The next directory - // re-scan would re-register them anyway. - ? el('span', {class:'tag', style:'opacity:.6'}, 'on disk') - : el('button', {class:'danger', onclick: async () => { - if (!confirm('Remove this image?')) return; - await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'}); - render('storage'); - }}, 'Remove')), + : el('button', {class:'danger', onclick: async () => { + if (!confirm('Remove this image?')) return; + await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'}); + render('storage'); + }}, 'Remove'), ]), ]); rowsAndEditors.push(tr, editorRow); @@ -702,95 +685,6 @@ el('span'), ])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')]; - // ── v0.4.65: Local directories (bind-mount workaround) ── - // For hosts (Unraid is the dominant case) where the kernel - // doesn't have NFS client modules loaded, in-container NFS - // mounts simply can't succeed. The pragmatic answer is the same - // one Bootimus / iVentoy / FOG use: mount the remote storage on - // the *host* then bind-mount the path into the container. - // OpenPXE reads from a regular directory — no protocol work, no - // capabilities, no kernel module dependency. - const ldPath = el('input', {type:'text', placeholder:'/mnt/external-isos'}); - const ldLabel = el('input', {type:'text', placeholder:'optional friendly name'}); - const ldMsg = el('div', {class:'msg'}); - const addLocalDir = el('button', {onclick: async () => { - if (!ldPath.value) { - ldMsg.replaceChildren(document.createTextNode('Path is required.')); - ldMsg.className='msg err'; return; - } - ldMsg.replaceChildren(document.createTextNode('Adding…')); - ldMsg.className='msg'; - const r = await postJSON('/api/local-dirs', { - path: ldPath.value, label: ldLabel.value || null, - }); - if (r.ok) { - ldMsg.replaceChildren(document.createTextNode('Added.')); - ldMsg.className='msg ok'; - render('storage'); - } else { - const t = await r.text(); - ldMsg.replaceChildren( - el('div', {}, [ - el('strong', {}, 'Add failed: '), - document.createTextNode(t), - ]), - ); - ldMsg.className='msg err'; - } - }}, 'Add directory'); - - const ldRows = localDirs.length ? localDirs.map(d => el('div', {class: 'nfs-row' + (d.last_error ? ' down' : '')}, [ - el('span', {class: 'dot ' + (d.last_error ? 'err' : 'ok')}), - el('div', {}, [ - el('div', {class:'id'}, d.label + ' · ' + d.path), - el('div', {class:'meta'}, - 'local · ' + - (d.iso_count != null ? d.iso_count + ' isos' : 'never scanned')), - d.last_error ? el('div', {class:'err'}, '⚠ ' + d.last_error) : null, - d.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, d.last_hint) : null, - ]), - el('button', {class:'ghost', onclick: async () => { - const r = await postJSON('/api/local-dirs/' + encodeURIComponent(d.id) + '/scan', {}); - if (r.ok) render('storage'); - }}, 'Re-scan'), - el('button', {class:'danger', onclick: async () => { - if (!confirm('Remove ' + d.path + '? ISOs from this directory will disappear from the menu.')) return; - await fetch('/api/local-dirs/' + encodeURIComponent(d.id), {method:'DELETE'}); - render('storage'); - }}, 'Remove'), - el('span'), - ])) : [el('div', {class:'empty'}, 'No local directories registered.')]; - - const localDirCard = el('div', {class:'card'}, [ - el('header', {}, [ - el('h2', {}, 'Local directories'), - el('span', {class:'sub'}, - localDirs.length + ' registered' + - (!caps.available ? ' · recommended on this host' : '')), - ]), - el('div', {class:'body'}, [ - el('p', {class:'msg', style:'margin-bottom:14px'}, - 'Bind-mount a host directory into the container (' + - 'e.g. -v /mnt/user/isos:/mnt/external-isos), then enter the ' + - 'in-container path here. OpenPXE will scan it for ISOs and ' + - 'surface them next to uploaded and NFS-mounted images. No ' + - 'CAP_SYS_ADMIN required — this is the container-friendly ' + - 'workaround when kernel NFS mounts can\'t work.'), - el('div', {class:'form-row'}, [ - el('label', {class:'field'}, [ - el('span', {class:'name'}, 'Path (inside container)'), - ldPath, - ]), - el('label', {class:'field'}, [ - el('span', {class:'name'}, 'Label (optional)'), - ldLabel, - ]), - ]), - addLocalDir, ldMsg, - el('div', {style:'margin-top:18px;display:grid;gap:8px'}, ldRows), - ]), - ]); - // Disk-space card. Free + used + total for the volume hosting the // ISO directory, with a coloured bar. Warns at 80% and goes red at // 95% so the operator sees the runway shrinking before uploads @@ -843,24 +737,6 @@ el('span', {class:'sub'}, mounts.length + ' configured'), ]), el('div', {class:'body'}, [ - // v0.4.65: if the host kernel doesn't have NFS client - // modules loaded (Unraid is the dominant case), in- - // container mounts will fail regardless of capabilities or - // privileged mode — there's nothing the operator can do - // from inside the container. Surface this prominently and - // point at the bind-mount workaround below. - !caps.available - ? el('div', {class:'msg err', style:'margin-bottom:14px'}, [ - el('div', {}, [ - el('strong', {}, 'Host kernel has no NFS client support.'), - document.createTextNode(' Mounting from this container will fail no matter what capabilities you grant it.'), - ]), - el('div', {style:'margin-top:6px;opacity:.85;font-size:12px'}, - 'Unraid is the most common case — its base kernel ships without the nfs/nfsv4 modules. ' + - 'Mount the share on the host (Unassigned Devices plugin, /etc/fstab, etc.), ' + - 'then bind-mount the resulting path into this container and register it as a Local directory below.'), - ]) - : null, el('div', {class:'form-row'}, [ el('label', {class:'field'}, [ el('span', {class:'name'}, 'NFS server'), @@ -881,14 +757,12 @@ addNfs, nfsMsg, el('div', {style:'margin-top:18px;display:grid;gap:8px'}, nfsRows), el('p', {class:'msg', style:'margin-top:14px'}, - 'Mounting NFS inside a container requires CAP_SYS_ADMIN, the ' + - 'mount.nfs binary (bundled in the default Docker image), and ' + - 'the host kernel having NFS client modules loaded. If any of ' + - 'these are missing, use Local directories below as a workaround.'), + 'Mounting NFS inside a container requires CAP_SYS_ADMIN and the ' + + 'mount.nfs binary (bundled in the default Docker image). On ' + + 'OpenShift, your SCC must allow CAP_SYS_ADMIN or you can run ' + + 'NFS mounts as a CSI driver outside the pod.'), ]), ]), - // v0.4.65: Local directories — the bind-mount workaround. - localDirCard, el('div', {class:'card'}, [ el('header', {}, [ el('h2', {}, 'Available images'),