Name update
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "pxeforge-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Shared types, config, and arch detection for PXEForge"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
time.workspace = true
|
||||
uuid.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tokio = { workspace = true, features = ["sync", "rt", "macros", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.12"
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Client architecture detection from DHCP options.
|
||||
//!
|
||||
//! Primary source is DHCP option 93 (Client System Architecture, RFC 4578/5970).
|
||||
//! Some firmwares report `0x0009` ("EFI BC") instead of `0x0007` — aliased here.
|
||||
//! Secondary: option 60 vendor-class with `HTTPClient` signals native UEFI HTTP
|
||||
//! boot, in which case we can skip TFTP and return an http:// URL directly.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ClientArch {
|
||||
/// Legacy x86 BIOS PXE (option 93 = 0x0000).
|
||||
LegacyX86,
|
||||
/// IA32 UEFI (option 93 = 0x0006).
|
||||
Ia32Uefi,
|
||||
/// x86_64 UEFI (option 93 = 0x0007 or 0x0009).
|
||||
X64Uefi,
|
||||
/// ARM32 UEFI (option 93 = 0x000A).
|
||||
Arm32Uefi,
|
||||
/// ARM64 UEFI (option 93 = 0x000B).
|
||||
Arm64Uefi,
|
||||
/// Unknown / unsupported architecture; caller should log and skip.
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
impl ClientArch {
|
||||
#[must_use]
|
||||
pub fn from_option_93(value: u16) -> Self {
|
||||
match value {
|
||||
0x0000 => Self::LegacyX86,
|
||||
0x0006 => Self::Ia32Uefi,
|
||||
0x0007 | 0x0009 => Self::X64Uefi,
|
||||
0x000A => Self::Arm32Uefi,
|
||||
0x000B => Self::Arm64Uefi,
|
||||
other => Self::Unknown(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default iPXE binary filename to return via TFTP for this architecture.
|
||||
/// Uses `snponly` variants which reuse the firmware's UNDI/SNP network
|
||||
/// stack — smaller binaries and broader hardware compatibility than the
|
||||
/// all-drivers-included `ipxe.efi`.
|
||||
#[must_use]
|
||||
pub fn ipxe_bootfile(self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
Self::LegacyX86 => "undionly.kpxe",
|
||||
Self::Ia32Uefi => "snponly-i386.efi",
|
||||
Self::X64Uefi => "snponly.efi",
|
||||
// ARM32 UEFI: upstream boot.ipxe.org does not publish a prebuilt
|
||||
// snponly variant for this arch. We return None so the DHCP
|
||||
// proxy declines rather than advertising a file we can't serve.
|
||||
Self::Arm32Uefi => return None,
|
||||
Self::Arm64Uefi => "snponly-arm64.efi",
|
||||
Self::Unknown(_) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::LegacyX86 => "bios",
|
||||
Self::Ia32Uefi => "uefi-ia32",
|
||||
Self::X64Uefi => "uefi-x64",
|
||||
Self::Arm32Uefi => "uefi-arm32",
|
||||
Self::Arm64Uefi => "uefi-arm64",
|
||||
Self::Unknown(_) => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which firmware class issued the DHCP request. Used to decide the reply
|
||||
/// path — PXEClient gets TFTP/iPXE chainload, HTTPClient gets an HTTP URL,
|
||||
/// iPXE itself gets the boot script URL.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FirmwareClass {
|
||||
/// Firmware PXE ROM (option 60 = "PXEClient").
|
||||
PxeClient,
|
||||
/// UEFI HTTP boot (option 60 = "HTTPClient").
|
||||
HttpClient,
|
||||
/// iPXE (option 77 user-class = "iPXE").
|
||||
IpxeUserClass,
|
||||
/// No recognised signature — likely not a PXE client at all.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl FirmwareClass {
|
||||
/// Classify a DHCP request. `vendor_class_60` is option 60 (vendor class
|
||||
/// identifier); `user_class_77` is option 77 (user class). Check user-class
|
||||
/// first because iPXE-that-we-chainloaded will set option 60 to PXEClient
|
||||
/// *and* option 77 to iPXE, and the iPXE classification wins.
|
||||
#[must_use]
|
||||
pub fn classify(vendor_class_60: Option<&[u8]>, user_class_77: Option<&[u8]>) -> Self {
|
||||
if let Some(uc) = user_class_77 {
|
||||
if uc.windows(b"iPXE".len()).any(|w| w == b"iPXE") {
|
||||
return Self::IpxeUserClass;
|
||||
}
|
||||
}
|
||||
match vendor_class_60 {
|
||||
Some(v) if v.starts_with(b"PXEClient") => Self::PxeClient,
|
||||
Some(v) if v.starts_with(b"HTTPClient") => Self::HttpClient,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn arch_aliases_0x0009_to_x64() {
|
||||
assert_eq!(ClientArch::from_option_93(0x0007), ClientArch::X64Uefi);
|
||||
assert_eq!(ClientArch::from_option_93(0x0009), ClientArch::X64Uefi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_and_arm() {
|
||||
assert_eq!(ClientArch::from_option_93(0x0000), ClientArch::LegacyX86);
|
||||
assert_eq!(ClientArch::from_option_93(0x000B), ClientArch::Arm64Uefi);
|
||||
assert!(matches!(
|
||||
ClientArch::from_option_93(0x1234),
|
||||
ClientArch::Unknown(0x1234)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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::Unknown(0xFFFF).ipxe_bootfile(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_class_detects_ipxe_over_pxeclient() {
|
||||
let c = FirmwareClass::classify(Some(b"PXEClient:Arch:00007"), Some(b"iPXE"));
|
||||
assert_eq!(c, FirmwareClass::IpxeUserClass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_class_http() {
|
||||
let c = FirmwareClass::classify(Some(b"HTTPClient:Arch:00016"), None);
|
||||
assert_eq!(c, FirmwareClass::HttpClient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firmware_class_plain_pxe() {
|
||||
let c = FirmwareClass::classify(Some(b"PXEClient"), None);
|
||||
assert_eq!(c, FirmwareClass::PxeClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! In-memory client state registry — the "who has contacted us" table the
|
||||
//! web UI displays. Not persisted: PXE sessions are ephemeral by nature.
|
||||
|
||||
use crate::arch::ClientArch;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ClientEvent {
|
||||
DhcpDiscover,
|
||||
DhcpRequest,
|
||||
PxeBootServerRequest,
|
||||
TftpRead { file: String },
|
||||
HttpScriptFetch { target: String },
|
||||
HttpIsoAsset { file: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientSnapshot {
|
||||
pub mac: String,
|
||||
pub last_ip: Option<IpAddr>,
|
||||
pub arch: Option<ClientArch>,
|
||||
pub hostname: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub first_seen: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub last_seen: OffsetDateTime,
|
||||
// Events are left with default serialization (9-tuple) — they're
|
||||
// diagnostic only and not consumed by the UI today.
|
||||
pub events: Vec<(OffsetDateTime, ClientEvent)>,
|
||||
/// The boot target (ISO id) last selected via the iPXE menu, if any.
|
||||
pub selected_target: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ClientRegistry {
|
||||
inner: RwLock<HashMap<String, ClientSnapshot>>,
|
||||
}
|
||||
|
||||
impl ClientRegistry {
|
||||
#[must_use]
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
pub fn record(
|
||||
&self,
|
||||
mac: &str,
|
||||
ip: Option<IpAddr>,
|
||||
arch: Option<ClientArch>,
|
||||
event: ClientEvent,
|
||||
) {
|
||||
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,
|
||||
});
|
||||
entry.last_seen = now;
|
||||
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;
|
||||
if entry.events.len() > MAX_EVENTS {
|
||||
let drop_n = entry.events.len() - MAX_EVENTS;
|
||||
entry.events.drain(..drop_n);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_selected_target(&self, mac: &str, target: Option<String>) {
|
||||
let mut guard = self.inner.write();
|
||||
if let Some(c) = guard.get_mut(mac) {
|
||||
c.selected_target = target;
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<ClientSnapshot> {
|
||||
let guard = self.inner.read();
|
||||
let mut v: Vec<_> = guard.values().cloned().collect();
|
||||
v.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
|
||||
v
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, mac: &str) -> Option<ClientSnapshot> {
|
||||
self.inner.read().get(mac).cloned()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub server: ServerConfig,
|
||||
pub network: NetworkConfig,
|
||||
pub paths: Paths,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ServerConfig {
|
||||
/// Address the web/API server binds to.
|
||||
pub http_bind: IpAddr,
|
||||
/// Port for the web/API + ISO/iPXE HTTP server (single port, multiplexed by path).
|
||||
pub http_port: u16,
|
||||
/// Address the TFTP server binds to.
|
||||
pub tftp_bind: IpAddr,
|
||||
/// Port for TFTP (RFC 1350 default is 69).
|
||||
pub tftp_port: u16,
|
||||
/// External hostname/IP clients should use to reach this server. If
|
||||
/// `None`, auto-detect from the interface that received the DHCP request
|
||||
/// (via IP_PKTINFO). This is what ends up in DHCP option 54 / siaddr,
|
||||
/// option 66 (TFTP server), and the base of generated iPXE URLs.
|
||||
pub public_ip: Option<Ipv4Addr>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct NetworkConfig {
|
||||
pub dhcp_mode: DhcpMode,
|
||||
/// Address the DHCP proxy/server binds to. For proxy mode, usually 0.0.0.0.
|
||||
pub dhcp_bind: IpAddr,
|
||||
/// UDP port for DHCP server-side receive. Standard is 67.
|
||||
pub dhcp_port: u16,
|
||||
/// UDP port for PXE Boot Server discovery. Standard is 4011.
|
||||
pub pxe_port: u16,
|
||||
/// Optional allowlist of client MAC prefixes (OUI). Empty = serve everyone.
|
||||
pub mac_allowlist: Vec<String>,
|
||||
/// Optional allowlist of subnets (CIDR). Empty = serve everyone.
|
||||
pub subnet_allowlist: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DhcpMode {
|
||||
/// Run as DHCP proxy (RFC 4578): reply with boot info only, don't lease
|
||||
/// IPs. Coexists with an existing DHCP server on the network. Default
|
||||
/// because it's the only mode that works in most real deployments without
|
||||
/// taking over address assignment.
|
||||
#[default]
|
||||
Proxy,
|
||||
/// Disabled — rely on an external DHCP server that has been manually
|
||||
/// configured with `next-server` / `filename`. PXEForge only serves TFTP
|
||||
/// + HTTP in this mode. Useful for home routers that can be pre-set.
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Paths {
|
||||
/// Directory holding uploaded ISO files.
|
||||
pub iso_dir: PathBuf,
|
||||
/// Directory for extracted kernel/initrd and other per-ISO derived assets.
|
||||
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).
|
||||
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
|
||||
/// `/var/lib/pxeforge/smb` in the container image.
|
||||
pub smb_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
http_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
http_port: 80,
|
||||
tftp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
tftp_port: 69,
|
||||
public_ip: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NetworkConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dhcp_mode: DhcpMode::Proxy,
|
||||
dhcp_bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
|
||||
dhcp_port: 67,
|
||||
pxe_port: 4011,
|
||||
mac_allowlist: Vec::new(),
|
||||
subnet_allowlist: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Paths {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
iso_dir: PathBuf::from("/var/lib/pxeforge/isos"),
|
||||
work_dir: PathBuf::from("/var/lib/pxeforge/work"),
|
||||
ipxe_dir: PathBuf::from("/usr/share/pxeforge/ipxe"),
|
||||
wimboot_path: None,
|
||||
smb_dir: PathBuf::from("/var/lib/pxeforge/smb"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server: ServerConfig::default(),
|
||||
network: NetworkConfig::default(),
|
||||
paths: Paths::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_toml_file(path: &Path) -> crate::Result<Self> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
toml::from_str(&text).map_err(|e| crate::Error::Config(e.to_string()))
|
||||
}
|
||||
|
||||
/// Apply environment variable overrides. Env var names follow the pattern
|
||||
/// `PXEFORGE_<SECTION>_<FIELD>`, uppercase. Unknown vars are ignored.
|
||||
/// Call this after loading the TOML file so env takes precedence.
|
||||
pub fn apply_env(&mut self) {
|
||||
if let Ok(v) = std::env::var("PXEFORGE_HTTP_PORT") {
|
||||
if let Ok(p) = v.parse() { self.server.http_port = p; }
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_TFTP_PORT") {
|
||||
if let Ok(p) = v.parse() { self.server.tftp_port = p; }
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_DHCP_PORT") {
|
||||
if let Ok(p) = v.parse() { self.network.dhcp_port = p; }
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_PUBLIC_IP") {
|
||||
if let Ok(ip) = v.parse() { self.server.public_ip = Some(ip); }
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_DHCP_MODE") {
|
||||
self.network.dhcp_mode = match v.to_ascii_lowercase().as_str() {
|
||||
"proxy" => DhcpMode::Proxy,
|
||||
"disabled" | "off" | "none" => DhcpMode::Disabled,
|
||||
_ => self.network.dhcp_mode,
|
||||
};
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_ISO_DIR") {
|
||||
self.paths.iso_dir = PathBuf::from(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_WORK_DIR") {
|
||||
self.paths.work_dir = PathBuf::from(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_IPXE_DIR") {
|
||||
self.paths.ipxe_dir = PathBuf::from(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("PXEFORGE_SMB_DIR") {
|
||||
self.paths.smb_dir = PathBuf::from(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("config: {0}")]
|
||||
Config(String),
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("invalid input: {0}")]
|
||||
Invalid(String),
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Gated Deployment queue.
|
||||
//!
|
||||
//! When a client selects "Gated Deployment" at the PXE menu, iPXE POSTs to
|
||||
//! `/api/gate/join` and receives a gate position. It then enters a poll
|
||||
//! loop hitting `/api/gate/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
|
||||
//! `POST /api/gate/assign { iso_id, gate_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.
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::Notify;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ClientArch;
|
||||
|
||||
/// Per-gate state visible to the WebUI.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Gate {
|
||||
pub id: String,
|
||||
/// 1-based race-gate position — position 1 is whoever got there first.
|
||||
pub position: u32,
|
||||
pub mac: String,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub arch: Option<ClientArch>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub joined_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub last_poll_at: OffsetDateTime,
|
||||
pub assigned_target: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct GateInner {
|
||||
id: String,
|
||||
position: u32,
|
||||
mac: String,
|
||||
ip: Option<IpAddr>,
|
||||
arch: Option<ClientArch>,
|
||||
joined_at: OffsetDateTime,
|
||||
last_poll_at: OffsetDateTime,
|
||||
assigned_target: Option<String>,
|
||||
/// Broadcast primitive that wakes the long-poll as soon as an
|
||||
/// assignment lands — no polling on our side, no sleep-loops.
|
||||
notify: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl GateInner {
|
||||
fn snapshot(&self) -> Gate {
|
||||
Gate {
|
||||
id: self.id.clone(),
|
||||
position: self.position,
|
||||
mac: self.mac.clone(),
|
||||
ip: self.ip,
|
||||
arch: self.arch,
|
||||
joined_at: self.joined_at,
|
||||
last_poll_at: self.last_poll_at,
|
||||
assigned_target: self.assigned_target.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GateQueue {
|
||||
inner: RwLock<HashMap<String, GateInner>>,
|
||||
}
|
||||
|
||||
impl GateQueue {
|
||||
#[must_use]
|
||||
pub fn new() -> Arc<Self> {
|
||||
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 —
|
||||
/// retrying iPXE clients don't duplicate their slot.
|
||||
pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> Gate {
|
||||
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; }
|
||||
return existing.snapshot();
|
||||
}
|
||||
|
||||
// Race 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 = GateInner {
|
||||
id: id.clone(),
|
||||
position: next_pos,
|
||||
mac: mac.to_string(),
|
||||
ip,
|
||||
arch,
|
||||
joined_at: now,
|
||||
last_poll_at: now,
|
||||
assigned_target: None,
|
||||
notify: Arc::new(Notify::new()),
|
||||
};
|
||||
let snap = inner.snapshot();
|
||||
guard.insert(id, inner);
|
||||
snap
|
||||
}
|
||||
|
||||
/// Look up the `Notify` primitive for a given gate id, for long-polling.
|
||||
#[must_use]
|
||||
pub fn notifier(&self, gate_id: &str) -> Option<Arc<Notify>> {
|
||||
self.inner.read().get(gate_id).map(|g| g.notify.clone())
|
||||
}
|
||||
|
||||
/// Update the last-poll timestamp (keeps the gate'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, gate_id: &str) -> Option<Gate> {
|
||||
let mut guard = self.inner.write();
|
||||
let g = guard.get_mut(gate_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
|
||||
/// queue are silently skipped.
|
||||
pub fn assign(&self, gate_ids: &[String], target: &str) -> usize {
|
||||
let mut guard = self.inner.write();
|
||||
let mut updated = 0;
|
||||
for id in gate_ids {
|
||||
if let Some(g) = guard.get_mut(id) {
|
||||
g.assigned_target = Some(target.to_string());
|
||||
g.notify.notify_waiters();
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
/// Remove a gate and return its final snapshot. Called after the client
|
||||
/// has successfully chained onto its assignment.
|
||||
pub fn release(&self, gate_id: &str) -> Option<Gate> {
|
||||
let mut guard = self.inner.write();
|
||||
let g = guard.remove(gate_id)?;
|
||||
g.notify.notify_waiters();
|
||||
// Renumber positions so the display stays contiguous (1..N). This
|
||||
// is O(N) but the queue is expected to be small (dozens of hosts).
|
||||
let mut remaining: Vec<_> = guard.values_mut().collect();
|
||||
remaining.sort_by_key(|g| g.position);
|
||||
for (i, g) in remaining.iter_mut().enumerate() {
|
||||
g.position = (i + 1) as u32;
|
||||
}
|
||||
Some(g.snapshot())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn list(&self) -> Vec<Gate> {
|
||||
let guard = self.inner.read();
|
||||
let mut v: Vec<_> = guard.values().map(GateInner::snapshot).collect();
|
||||
v.sort_by_key(|g| g.position);
|
||||
v
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.read().len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn join_assigns_sequential_positions() {
|
||||
let q = GateQueue::new();
|
||||
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
|
||||
let g2 = q.join("aa:bb:cc:00:00:02", None, None);
|
||||
let g3 = q.join("aa:bb:cc:00:00:03", None, None);
|
||||
assert_eq!(g1.position, 1);
|
||||
assert_eq!(g2.position, 2);
|
||||
assert_eq!(g3.position, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejoining_same_mac_is_idempotent() {
|
||||
let q = GateQueue::new();
|
||||
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
|
||||
let g2 = q.join("aa:bb:cc:00:00:01", None, None);
|
||||
assert_eq!(g1.id, g2.id);
|
||||
assert_eq!(g1.position, g2.position);
|
||||
assert_eq!(q.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assign_broadcasts_target() {
|
||||
let q = GateQueue::new();
|
||||
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
|
||||
let g2 = q.join("aa:bb:cc:00:00:02", None, None);
|
||||
let n = q.assign(&[g1.id.clone(), g2.id.clone()], "ubuntu-24-04-linux");
|
||||
assert_eq!(n, 2);
|
||||
for g in q.list() {
|
||||
assert_eq!(g.assigned_target.as_deref(), Some("ubuntu-24-04-linux"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_renumbers() {
|
||||
let q = GateQueue::new();
|
||||
let a = q.join("aa:00:00:00:00:01", None, None);
|
||||
let _b = q.join("aa:00:00:00:00:02", None, None);
|
||||
let c = q.join("aa:00:00:00:00:03", None, None);
|
||||
q.release(&a.id);
|
||||
let list = q.list();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].position, 1);
|
||||
assert_eq!(list[1].position, 2);
|
||||
// c had position 3, now renumbered to 2.
|
||||
assert_eq!(list[1].id, c.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assign_wakes_waiter() {
|
||||
let q = GateQueue::new();
|
||||
let g = q.join("aa:00:00:00:00:01", None, None);
|
||||
let notify = q.notifier(&g.id).unwrap();
|
||||
|
||||
let q2 = Arc::new(q);
|
||||
let q3 = q2.clone();
|
||||
let id = g.id.clone();
|
||||
let fut = tokio::spawn(async move {
|
||||
notify.notified().await;
|
||||
q3.touch(&id)
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
q2.assign(&[g.id.clone()], "x");
|
||||
let result = fut.await.unwrap();
|
||||
assert!(result.is_some());
|
||||
assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! PXEForge shared core: config, arch detection, client state registry,
|
||||
//! runtime settings, and the Gated Deployment queue.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod arch;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod gate;
|
||||
pub mod log_bus;
|
||||
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 gate::{Gate, GateQueue};
|
||||
pub use log_bus::{LogBus, LogBusLayer, LogLine};
|
||||
pub use settings::{Settings, SettingsStore, TimeoutAction};
|
||||
@@ -0,0 +1,200 @@
|
||||
//! In-process log bus.
|
||||
//!
|
||||
//! The web UI's Terminal tab streams live server logs over SSE. To feed it
|
||||
//! we install a `tracing_subscriber::Layer` that captures formatted lines
|
||||
//! and pushes them onto:
|
||||
//!
|
||||
//! 1. A bounded `tokio::sync::broadcast` channel for live subscribers.
|
||||
//! 2. A small in-memory ring buffer (default 500 lines) so a UI that
|
||||
//! connects mid-session sees recent context, not a blank pane.
|
||||
//!
|
||||
//! No file logging happens here — Docker/OpenShift already capture stdout.
|
||||
//! This is purely an extra fan-out for the UI.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing::{Event, Level, Subscriber};
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
use tracing_subscriber::Layer;
|
||||
|
||||
/// One captured log line. Cheap to clone (small struct, short strings).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogLine {
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub timestamp: OffsetDateTime,
|
||||
/// Lowercase: `error`, `warn`, `info`, `debug`, `trace`.
|
||||
pub level: String,
|
||||
pub target: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl LogLine {
|
||||
/// Compact one-line "tail -f"-style render.
|
||||
#[must_use]
|
||||
pub fn render(&self) -> String {
|
||||
// 2026-04-29T12:34:56Z [info] pxeforge::http: HTTP listening on 0.0.0.0:80
|
||||
let ts = self
|
||||
.timestamp
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_else(|_| "?".into());
|
||||
format!(
|
||||
"{ts} [{:>5}] {}: {}",
|
||||
self.level, self.target, self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LogBus {
|
||||
tx: broadcast::Sender<LogLine>,
|
||||
buf: Mutex<VecDeque<LogLine>>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
impl LogBus {
|
||||
#[must_use]
|
||||
pub fn new(capacity: usize) -> Arc<Self> {
|
||||
// 256 = upper bound on concurrent live subscribers' lag tolerance.
|
||||
// If a slow client falls behind it'll get a Lagged error and skip
|
||||
// ahead — which is what we want for a live tail.
|
||||
let (tx, _) = broadcast::channel(256);
|
||||
Arc::new(Self {
|
||||
tx,
|
||||
buf: Mutex::new(VecDeque::with_capacity(capacity)),
|
||||
cap: capacity,
|
||||
})
|
||||
}
|
||||
|
||||
/// Subscribe to new log lines as they're emitted.
|
||||
#[must_use]
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<LogLine> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
/// Snapshot of the recent ring buffer (oldest → newest).
|
||||
#[must_use]
|
||||
pub fn recent(&self) -> Vec<LogLine> {
|
||||
self.buf.lock().iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Drop everything in the recent ring buffer.
|
||||
pub fn clear(&self) {
|
||||
self.buf.lock().clear();
|
||||
}
|
||||
|
||||
/// Manually push a synthetic log line (used by the terminal-command
|
||||
/// handler so operator commands appear inline in the live tail).
|
||||
pub fn push(&self, level: &str, target: &str, message: impl Into<String>) {
|
||||
let line = LogLine {
|
||||
timestamp: OffsetDateTime::now_utc(),
|
||||
level: level.to_string(),
|
||||
target: target.to_string(),
|
||||
message: message.into(),
|
||||
};
|
||||
self.record(line);
|
||||
}
|
||||
|
||||
fn record(&self, line: LogLine) {
|
||||
{
|
||||
let mut g = self.buf.lock();
|
||||
if g.len() == self.cap {
|
||||
g.pop_front();
|
||||
}
|
||||
g.push_back(line.clone());
|
||||
}
|
||||
// Send errors are fine — just means no live subscribers right now.
|
||||
let _ = self.tx.send(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// `tracing_subscriber::Layer` that funnels every event into the LogBus.
|
||||
///
|
||||
/// Install once in `main` alongside the existing `fmt::layer()` so console
|
||||
/// output and the UI tail see the same stream.
|
||||
pub struct LogBusLayer {
|
||||
bus: Arc<LogBus>,
|
||||
}
|
||||
|
||||
impl LogBusLayer {
|
||||
#[must_use]
|
||||
pub fn new(bus: Arc<LogBus>) -> Self {
|
||||
Self { bus }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for LogBusLayer
|
||||
where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
let meta = event.metadata();
|
||||
let level = match *meta.level() {
|
||||
Level::ERROR => "error",
|
||||
Level::WARN => "warn",
|
||||
Level::INFO => "info",
|
||||
Level::DEBUG => "debug",
|
||||
Level::TRACE => "trace",
|
||||
};
|
||||
let mut visitor = MessageVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
let line = LogLine {
|
||||
timestamp: OffsetDateTime::now_utc(),
|
||||
level: level.to_string(),
|
||||
target: meta.target().to_string(),
|
||||
message: visitor.message,
|
||||
};
|
||||
self.bus.record(line);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MessageVisitor {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl Visit for MessageVisitor {
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
if field.name() == "message" {
|
||||
self.message = value.to_string();
|
||||
}
|
||||
}
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
self.message = format!("{value:?}").trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn push_and_recent() {
|
||||
let bus = LogBus::new(3);
|
||||
bus.push("info", "test", "first");
|
||||
bus.push("info", "test", "second");
|
||||
bus.push("info", "test", "third");
|
||||
bus.push("info", "test", "fourth");
|
||||
let r = bus.recent();
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0].message, "second");
|
||||
assert_eq!(r[2].message, "fourth");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn subscribe_sees_new_lines() {
|
||||
let bus = LogBus::new(8);
|
||||
let mut rx = bus.subscribe();
|
||||
bus.push("info", "test", "live");
|
||||
let l = rx.recv().await.unwrap();
|
||||
assert_eq!(l.message, "live");
|
||||
assert_eq!(l.level, "info");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Runtime-mutable settings, distinct from the static `Config`.
|
||||
//!
|
||||
//! Rationale: `Config` holds bind addresses, paths, and other things that
|
||||
//! can only reasonably change at process start. `Settings` holds everything
|
||||
//! the web UI can flip at runtime: timeouts, default boot action, Windows
|
||||
//! feature toggles, etc. Persisted to `<work_dir>/settings.json` so they
|
||||
//! survive pod restarts without requiring a ConfigMap edit.
|
||||
//!
|
||||
//! **Crucial property:** every UI-facing "feature flag" in here maps to a
|
||||
//! specific iPXE script-generation behavior elsewhere in the codebase. The
|
||||
//! user never writes iPXE; they toggle a setting and we translate.
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
/// Seconds to wait at the top-level boot menu before falling through
|
||||
/// to `timeout_action`. Default 600s per the Phase 2 spec.
|
||||
pub boot_menu_timeout_secs: u32,
|
||||
|
||||
/// What happens if the boot-menu timer hits zero with no selection.
|
||||
pub timeout_action: TimeoutAction,
|
||||
|
||||
/// Master enable for Windows ISO support. When off, Windows ISOs are
|
||||
/// listed in the UI as "Windows — disabled" and not exposed in the
|
||||
/// PXE menu. Off by default: Windows support requires bundling Samba
|
||||
/// and wimlib in the runtime image (see deploy/docker/Dockerfile).
|
||||
pub windows_enabled: bool,
|
||||
|
||||
/// SMB share hostname/IP the patched WinPE's startnet.cmd will
|
||||
/// `net use` against. Empty string = auto-fill with the public IP at
|
||||
/// script-generation time.
|
||||
pub smb_host_override: String,
|
||||
|
||||
/// Global kernel-args append (added to every Linux entry's cmdline).
|
||||
/// Useful for things like `console=ttyS0,115200` on serial-only boxes.
|
||||
/// Do NOT accept raw iPXE script fragments here; this is literal kernel
|
||||
/// args only.
|
||||
pub extra_kernel_args: String,
|
||||
|
||||
/// If true, the "Default → Boot from Local HDD" menu item is the
|
||||
/// pre-selected entry (and is what the timeout falls to if
|
||||
/// `timeout_action = LocalHdd`).
|
||||
pub default_local_hdd: bool,
|
||||
|
||||
/// When a client hits the Gated Deployment item, how long (seconds) to
|
||||
/// hold it at the gate before giving up and falling back to the menu.
|
||||
/// 0 = forever.
|
||||
pub gate_wait_max_secs: u32,
|
||||
|
||||
/// Optional DNS server advertised on the Network tab. Purely
|
||||
/// informational today — PXEForge does not run a DNS server, but
|
||||
/// operators expect to be able to record what the upstream DNS is.
|
||||
/// Empty string = unset (UI shows placeholder).
|
||||
pub dns_server: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TimeoutAction {
|
||||
/// Sit at the menu forever (no fallthrough).
|
||||
Stay,
|
||||
/// Chain the "Boot from Local HDD" entry.
|
||||
LocalHdd,
|
||||
/// Put the client into the gate queue, waiting for operator assignment.
|
||||
#[default]
|
||||
GatedDeployment,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
boot_menu_timeout_secs: 600,
|
||||
timeout_action: TimeoutAction::GatedDeployment,
|
||||
windows_enabled: false,
|
||||
smb_host_override: String::new(),
|
||||
extra_kernel_args: String::new(),
|
||||
default_local_hdd: true,
|
||||
gate_wait_max_secs: 0,
|
||||
dns_server: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SettingsStore {
|
||||
path: PathBuf,
|
||||
inner: RwLock<Settings>,
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
/// Load from `work_dir/settings.json`, or create with defaults if the
|
||||
/// file is missing/corrupt. Never fails — a bad settings file on disk
|
||||
/// is not a reason to refuse to start.
|
||||
pub fn load_or_default(work_dir: &Path) -> Arc<Self> {
|
||||
let path = work_dir.join("settings.json");
|
||||
let initial = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => match serde_json::from_str::<Settings>(&text) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "pxeforge::settings",
|
||||
"settings.json present but unreadable ({e}); falling back to defaults"
|
||||
);
|
||||
Settings::default()
|
||||
}
|
||||
},
|
||||
Err(_) => Settings::default(),
|
||||
};
|
||||
Arc::new(Self { path, inner: RwLock::new(initial) })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Settings {
|
||||
self.inner.read().clone()
|
||||
}
|
||||
|
||||
/// Atomically replace settings and persist. The caller supplies the full
|
||||
/// `Settings` struct — partial updates happen at the HTTP layer via
|
||||
/// merge-then-store. Persistence errors are logged but not returned;
|
||||
/// settings live in memory authoritatively and only SHOULD be on disk.
|
||||
pub fn replace(&self, new: Settings) {
|
||||
{
|
||||
let mut g = self.inner.write();
|
||||
*g = new;
|
||||
}
|
||||
let snap = self.snapshot();
|
||||
if let Err(e) = self.persist(&snap) {
|
||||
tracing::warn!(target: "pxeforge::settings", "failed to persist settings: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(&self, s: &Settings) -> std::io::Result<()> {
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
if let Some(parent) = self.path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let body = serde_json::to_vec_pretty(s)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
std::fs::write(&tmp, body)?;
|
||||
std::fs::rename(tmp, &self.path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn defaults_roundtrip() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = SettingsStore::load_or_default(dir.path());
|
||||
let s = store.snapshot();
|
||||
assert_eq!(s.boot_menu_timeout_secs, 600);
|
||||
assert_eq!(s.timeout_action, TimeoutAction::GatedDeployment);
|
||||
assert!(!s.windows_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_persists() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = SettingsStore::load_or_default(dir.path());
|
||||
let mut new = store.snapshot();
|
||||
new.boot_menu_timeout_secs = 30;
|
||||
new.windows_enabled = true;
|
||||
store.replace(new);
|
||||
// Reload from disk.
|
||||
drop(store);
|
||||
let reloaded = SettingsStore::load_or_default(dir.path());
|
||||
let s = reloaded.snapshot();
|
||||
assert_eq!(s.boot_menu_timeout_secs, 30);
|
||||
assert!(s.windows_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_file_falls_back_to_default() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("settings.json"), b"{ not json }").unwrap();
|
||||
let store = SettingsStore::load_or_default(dir.path());
|
||||
assert_eq!(store.snapshot().boot_menu_timeout_secs, 600);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user