188 lines
6.8 KiB
Rust
188 lines
6.8 KiB
Rust
//! 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);
|
|
}
|
|
}
|