Revert "v0.4.65: Local directory ISO source (bind-mount workaround for Unraid)"
This reverts commit 72a2089c98.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 `<work_dir>/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<String>,
|
||||
pub last_hint: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_scan: Option<OffsetDateTime>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Inner {
|
||||
dirs: HashMap<String, LocalDirSpec>,
|
||||
}
|
||||
|
||||
/// Manages bind-mounted host directories and surfaces their ISOs.
|
||||
///
|
||||
/// Cheap to clone — internal state is behind `Arc<Mutex<...>>`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalDirManager {
|
||||
state_path: Arc<PathBuf>,
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
iso_store: IsoStore,
|
||||
}
|
||||
|
||||
impl LocalDirManager {
|
||||
/// Construct a manager that persists state to
|
||||
/// `<work_dir>/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::<Vec<LocalDirSpec>>(&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<LocalDirSpec> {
|
||||
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<u32> {
|
||||
self.rescan_inner(id).await
|
||||
}
|
||||
|
||||
/// Snapshot of every registered directory, sorted by id for stable
|
||||
/// UI rendering.
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<LocalDirSpec> {
|
||||
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<LocalDirSpec> {
|
||||
self.inner.lock().dirs.get(id).cloned()
|
||||
}
|
||||
|
||||
// ── internals ─────────────────────────────────────────────────────
|
||||
|
||||
async fn rescan_inner(&self, id: &str) -> Result<u32> {
|
||||
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<String>,
|
||||
hint: Option<String>,
|
||||
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<LocalDirSpec> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,8 @@ use tokio::io::AsyncWriteExt;
|
||||
///
|
||||
/// The default is `Local` — uploaded ISOs sit in `<iso_dir>/<id>.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<RwLock<Option<PathBuf>>>,
|
||||
/// 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<RwLock<HashMap<String, PathBuf>>>,
|
||||
inner: Arc<RwLock<Inner>>,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user