Name update

This commit is contained in:
Miles Ward
2026-04-29 02:47:00 -04:00
commit 3517c67831
66 changed files with 9016 additions and 0 deletions
+187
View File
@@ -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);
}
}