Name update

This commit is contained in:
Miles Ward
2026-05-21 02:13:08 -04:00
parent 91848e02e3
commit 115ba779da
37 changed files with 1387 additions and 589 deletions
+4 -1
View File
@@ -126,7 +126,10 @@ mod tests {
fn bootfile_names_stable() {
assert_eq!(ClientArch::LegacyX86.ipxe_bootfile(), Some("undionly.kpxe"));
assert_eq!(ClientArch::X64Uefi.ipxe_bootfile(), Some("snponly.efi"));
assert_eq!(ClientArch::Arm64Uefi.ipxe_bootfile(), Some("snponly-arm64.efi"));
assert_eq!(
ClientArch::Arm64Uefi.ipxe_bootfile(),
Some("snponly-arm64.efi")
);
assert_eq!(ClientArch::Unknown(0xFFFF).ipxe_bootfile(), None);
}
+18 -12
View File
@@ -56,19 +56,25 @@ impl ClientRegistry {
) {
let mut guard = self.inner.write();
let now = OffsetDateTime::now_utc();
let entry = guard.entry(mac.to_string()).or_insert_with(|| ClientSnapshot {
mac: mac.to_string(),
last_ip: ip,
arch,
hostname: None,
first_seen: now,
last_seen: now,
events: Vec::new(),
selected_target: None,
});
let entry = guard
.entry(mac.to_string())
.or_insert_with(|| ClientSnapshot {
mac: mac.to_string(),
last_ip: ip,
arch,
hostname: None,
first_seen: now,
last_seen: now,
events: Vec::new(),
selected_target: None,
});
entry.last_seen = now;
if ip.is_some() { entry.last_ip = ip; }
if arch.is_some() { entry.arch = arch; }
if ip.is_some() {
entry.last_ip = ip;
}
if arch.is_some() {
entry.arch = arch;
}
entry.events.push((now, event));
// Cap event history per client to keep memory bounded.
const MAX_EVENTS: usize = 64;
+13 -5
View File
@@ -68,7 +68,7 @@ pub struct Paths {
pub work_dir: PathBuf,
/// Directory containing bundled iPXE binaries (undionly.kpxe, snponly.efi, ...).
pub ipxe_dir: PathBuf,
/// Path to the wimboot binary for Windows ISOs (optional — feature-gated).
/// Path to the wimboot binary for Windows ISOs (optional — feature-controlled).
pub wimboot_path: Option<PathBuf>,
/// Directory under which Windows ISOs are extracted and served via SMB.
/// Only used when `settings.windows_enabled = true`. Defaults to
@@ -128,16 +128,24 @@ impl Config {
/// Call this after loading the TOML file so env takes precedence.
pub fn apply_env(&mut self) {
if let Ok(v) = std::env::var("OPENPXE_HTTP_PORT") {
if let Ok(p) = v.parse() { self.server.http_port = p; }
if let Ok(p) = v.parse() {
self.server.http_port = p;
}
}
if let Ok(v) = std::env::var("OPENPXE_TFTP_PORT") {
if let Ok(p) = v.parse() { self.server.tftp_port = p; }
if let Ok(p) = v.parse() {
self.server.tftp_port = p;
}
}
if let Ok(v) = std::env::var("OPENPXE_DHCP_PORT") {
if let Ok(p) = v.parse() { self.network.dhcp_port = p; }
if let Ok(p) = v.parse() {
self.network.dhcp_port = p;
}
}
if let Ok(v) = std::env::var("OPENPXE_PUBLIC_IP") {
if let Ok(ip) = v.parse() { self.server.public_ip = Some(ip); }
if let Ok(ip) = v.parse() {
self.server.public_ip = Some(ip);
}
}
if let Ok(v) = std::env::var("OPENPXE_DHCP_MODE") {
self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() {
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct HostBinding {
/// don't have to worry about case.
pub mac: String,
/// Preferred boot entry id (matches a `BootEntry::id` in the iso
/// store) OR one of the reserved menu names: `_local`, `_gate`,
/// store) OR one of the reserved menu names: `_local`, `_queue`,
/// `_tools_menu`. Empty string falls back to the menu.
pub target: String,
/// Optional human-readable label shown in the UI (`"Tom's laptop"`,
+2 -2
View File
@@ -6,18 +6,18 @@ pub mod arch;
pub mod client;
pub mod config;
pub mod error;
pub mod queue;
pub mod host_bindings;
pub mod log_bus;
pub mod metrics;
pub mod queue;
pub mod settings;
pub use arch::{ClientArch, FirmwareClass};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result};
pub use queue::{Gate, DeploymentQueue};
pub use host_bindings::{normalize_mac, HostBinding, HostBindings};
pub use log_bus::{LogBus, LogBusLayer, LogLine};
pub use metrics::{HttpRoute, Metrics};
pub use queue::{DeploymentQueue, QueueEntry};
pub use settings::{Settings, SettingsStore, TimeoutAction};
+62 -11
View File
@@ -87,7 +87,9 @@ impl Metrics {
}
pub fn record_tftp_err(&self) {
self.inner.tftp_transfers_err.fetch_add(1, Ordering::Relaxed);
self.inner
.tftp_transfers_err
.fetch_add(1, Ordering::Relaxed);
}
// ── HTTP ───────────────────────────────────────────────────────────
@@ -181,7 +183,10 @@ impl Metrics {
"",
);
let _ = writeln!(out, "# HELP openpxe_tftp_transfers_total TFTP transfers, by status.");
let _ = writeln!(
out,
"# HELP openpxe_tftp_transfers_total TFTP transfers, by status."
);
let _ = writeln!(out, "# TYPE openpxe_tftp_transfers_total counter");
let _ = writeln!(
out,
@@ -201,7 +206,10 @@ impl Metrics {
"",
);
let _ = writeln!(out, "# HELP openpxe_http_requests_total HTTP requests served, by route family.");
let _ = writeln!(
out,
"# HELP openpxe_http_requests_total HTTP requests served, by route family."
);
let _ = writeln!(out, "# TYPE openpxe_http_requests_total counter");
for (label, counter) in [
("boot_script", &i.http_boot_script),
@@ -218,14 +226,53 @@ impl Metrics {
}
// Gauges.
write_gauge(&mut out, "openpxe_iso_count", "ISOs currently registered (local + NFS).", i.iso_count.load(Ordering::Relaxed), "");
write_gauge(&mut out, "openpxe_client_count", "PXE clients seen this process lifetime.", i.client_count.load(Ordering::Relaxed), "");
write_gauge(&mut out, "openpxe_queue_count", "Clients currently waiting at the deployment queue.", i.queue_count.load(Ordering::Relaxed), "");
write_gauge(&mut out, "openpxe_queue_imaging", "Clients currently imaging (queue + assigned target).", i.queue_imaging.load(Ordering::Relaxed), "");
write_gauge(&mut out, "openpxe_nfs_mounts_active", "NFS shares currently mounted.", i.nfs_mounts_active.load(Ordering::Relaxed), "");
write_gauge(&mut out, "openpxe_uptime_seconds", "Seconds since this OpenPXE instance started.", uptime_secs, "");
write_gauge(
&mut out,
"openpxe_iso_count",
"ISOs currently registered (local + NFS).",
i.iso_count.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_client_count",
"PXE clients seen this process lifetime.",
i.client_count.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_queue_count",
"Clients currently waiting at the deployment queue.",
i.queue_count.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_queue_imaging",
"Clients currently imaging (queue + assigned target).",
i.queue_imaging.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_nfs_mounts_active",
"NFS shares currently mounted.",
i.nfs_mounts_active.load(Ordering::Relaxed),
"",
);
write_gauge(
&mut out,
"openpxe_uptime_seconds",
"Seconds since this OpenPXE instance started.",
uptime_secs,
"",
);
let _ = writeln!(out, "# HELP openpxe_build_info Build metadata. Always 1; the version is in the label.");
let _ = writeln!(
out,
"# HELP openpxe_build_info Build metadata. Always 1; the version is in the label."
);
let _ = writeln!(out, "# TYPE openpxe_build_info gauge");
let _ = writeln!(out, "openpxe_build_info{{version=\"{version}\"}} 1");
@@ -259,7 +306,11 @@ mod tests {
m.record_http(HttpRoute::Api);
m.set_iso_count(3);
let out = m.render("0.2.0", 42);
assert_eq!(out.matches("# TYPE openpxe_dhcp_replies_total counter").count(), 1);
assert_eq!(
out.matches("# TYPE openpxe_dhcp_replies_total counter")
.count(),
1
);
assert_eq!(out.matches("# TYPE openpxe_iso_count gauge").count(), 1);
assert!(out.contains("openpxe_dhcp_replies_total{arch=\"uefi\"} 1"));
assert!(out.contains("openpxe_dhcp_replies_total{arch=\"bios\"} 1"));
+28 -24
View File
@@ -1,16 +1,16 @@
//! Queued Deployment queue.
//!
//! When a client selects "Queued Deployment" at the PXE menu, iPXE POSTs to
//! `/api/queue/join` and receives a gate position. It then enters a poll
//! `/api/queue/join` and receives a queue position. It then enters a poll
//! loop hitting `/api/queue/poll/<id>`; the server holds the request open
//! until either (a) the operator assigns an ISO from the WebUI, in which
//! case the poll returns an iPXE `chain` URL, or (b) the poll times out
//! (iPXE's HTTP client has its own timeout), in which case iPXE re-POSTs.
//!
//! The WebUI shows the queue (`GET /api/gate`) and issues
//! The WebUI shows the queue (`GET /api/queue`) and issues
//! `POST /api/queue/assign { iso_id, entry_ids: [...] }` to launch a single
//! ISO across many gated clients at once. This is the "horse-race gate"
//! UX the user asked for — every horse leaves the line simultaneously.
//! ISO across many queued clients at once. Every waiting machine receives
//! the assignment without operator visits at the rack.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
@@ -23,11 +23,11 @@ use uuid::Uuid;
use crate::ClientArch;
/// Per-gate state visible to the WebUI.
/// Per-client queue state visible to the WebUI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Gate {
pub struct QueueEntry {
pub id: String,
/// 1-based race-gate position — position 1 is whoever got there first.
/// 1-based queue position — position 1 is whoever got there first.
pub position: u32,
pub mac: String,
pub ip: Option<IpAddr>,
@@ -55,8 +55,8 @@ struct QueueEntryInner {
}
impl QueueEntryInner {
fn snapshot(&self) -> Gate {
Gate {
fn snapshot(&self) -> QueueEntry {
QueueEntry {
id: self.id.clone(),
position: self.position,
mac: self.mac.clone(),
@@ -80,21 +80,25 @@ impl DeploymentQueue {
Arc::new(Self::default())
}
/// Add a client to the gate. Returns the new `Gate` snapshot. If the
/// MAC is already queued, the existing gate is returned unchanged —
/// Add a client to the queue. Returns the current queue snapshot. If the
/// MAC is already queued, the existing entry is returned unchanged —
/// retrying iPXE clients don't duplicate their slot.
pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> Gate {
pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> QueueEntry {
let now = OffsetDateTime::now_utc();
let mut guard = self.inner.write();
if let Some(existing) = guard.values_mut().find(|g| g.mac == mac) {
existing.last_poll_at = now;
if ip.is_some() { existing.ip = ip; }
if arch.is_some() { existing.arch = arch; }
if ip.is_some() {
existing.ip = ip;
}
if arch.is_some() {
existing.arch = arch;
}
return existing.snapshot();
}
// Race position = max(position) + 1, or 1 if empty.
// Queue position = max(position) + 1, or 1 if empty.
let next_pos = guard.values().map(|g| g.position).max().unwrap_or(0) + 1;
let id = Uuid::new_v4().to_string();
let inner = QueueEntryInner {
@@ -113,24 +117,24 @@ impl DeploymentQueue {
snap
}
/// Look up the `Notify` primitive for a given gate id, for long-polling.
/// Look up the `Notify` primitive for a given queue entry id, for long-polling.
#[must_use]
pub fn notifier(&self, entry_id: &str) -> Option<Arc<Notify>> {
self.inner.read().get(entry_id).map(|g| g.notify.clone())
}
/// Update the last-poll timestamp (keeps the gate's "live" indicator
/// Update the last-poll timestamp (keeps the queue's "live" indicator
/// fresh in the UI) and return the current snapshot. Returns None if
/// the gate was released/expired between requests.
pub fn touch(&self, entry_id: &str) -> Option<Gate> {
/// the entry was released/expired between requests.
pub fn touch(&self, entry_id: &str) -> Option<QueueEntry> {
let mut guard = self.inner.write();
let g = guard.get_mut(entry_id)?;
g.last_poll_at = OffsetDateTime::now_utc();
Some(g.snapshot())
}
/// Operator assigns an ISO entry (boot_entry id) to one or more gates.
/// Returns the number of gates that were updated. Gates not in the
/// Operator assigns an ISO entry (boot_entry id) to one or more clients.
/// Returns the number of queue entries that were updated. Entries not in the
/// queue are silently skipped.
pub fn assign(&self, entry_ids: &[String], target: &str) -> usize {
let mut guard = self.inner.write();
@@ -145,9 +149,9 @@ impl DeploymentQueue {
updated
}
/// Remove a gate and return its final snapshot. Called after the client
/// Remove a queue entry and return its final snapshot. Called after the client
/// has successfully chained onto its assignment.
pub fn release(&self, entry_id: &str) -> Option<Gate> {
pub fn release(&self, entry_id: &str) -> Option<QueueEntry> {
let mut guard = self.inner.write();
let g = guard.remove(entry_id)?;
g.notify.notify_waiters();
@@ -162,7 +166,7 @@ impl DeploymentQueue {
}
#[must_use]
pub fn list(&self) -> Vec<Gate> {
pub fn list(&self) -> Vec<QueueEntry> {
let guard = self.inner.read();
let mut v: Vec<_> = guard.values().map(QueueEntryInner::snapshot).collect();
v.sort_by_key(|g| g.position);
+14 -8
View File
@@ -48,9 +48,9 @@ pub struct Settings {
pub default_local_hdd: bool,
/// When a client hits the Queued Deployment item, how long (seconds) to
/// hold it at the gate before giving up and falling back to the menu.
/// hold it in queue before giving up and falling back to the menu.
/// 0 = forever.
pub gate_wait_max_secs: u32,
pub queue_wait_max_secs: u32,
/// Optional DNS server advertised on the Network tab. Purely
/// informational today — OpenPXE does not run a DNS server, but
@@ -67,11 +67,8 @@ pub enum TimeoutAction {
/// Chain the "Boot from Local HDD" entry.
LocalHdd,
/// Put the client into the deployment queue, waiting for operator
/// assignment. The serde alias keeps v0.2.0 settings.json files
/// readable after the v0.3.0 rename — old `"gated_deployment"`
/// values deserialize transparently.
/// assignment.
#[default]
#[serde(alias = "gated_deployment")]
QueuedDeployment,
}
@@ -84,7 +81,7 @@ impl Default for Settings {
smb_host_override: String::new(),
extra_kernel_args: String::new(),
default_local_hdd: true,
gate_wait_max_secs: 0,
queue_wait_max_secs: 0,
dns_server: String::new(),
}
}
@@ -115,7 +112,10 @@ impl SettingsStore {
},
Err(_) => Settings::default(),
};
Arc::new(Self { path, inner: RwLock::new(initial) })
Arc::new(Self {
path,
inner: RwLock::new(initial),
})
}
#[must_use]
@@ -181,6 +181,12 @@ mod tests {
assert!(s.windows_enabled);
}
#[test]
fn settings_serialize_queue_naming() {
let text = serde_json::to_string(&Settings::default()).unwrap();
assert!(text.contains("queue_wait_max_secs"));
}
#[test]
fn corrupt_file_falls_back_to_default() {
let dir = tempdir().unwrap();