Name update
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user