Name update
This commit is contained in:
@@ -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