//! Per-host deployment profile. //! //! v0.5.2: a small, optional bundle of "what should this machine do when //! it images" attached to either a pinned host binding ([`crate::HostBinding`]) //! or a queued device ([`crate::QueueEntry`]). All three fields are //! optional and independent: //! //! * `auto_hostname` — substituted into the served unattended answer file //! (`{{HOSTNAME}}`) so the installer sets the machine name. //! * `auto_ip` — substituted as `{{IP}}`. OpenPXE is a DHCP **proxy** and //! does not hand out leases, so this is applied by the installer as a //! static-network directive inside the answer file, not by DHCP. //! * `unattended_file` — the id of an uploaded file in the unattended //! store (Kickstart / Preseed / Autoinstall / Windows answer file). When //! set, the boot chain injects the appropriate kernel argument so the //! install runs unattended. use serde::{Deserialize, Serialize}; /// Optional deployment hints carried on a host pin or a queue entry. /// /// The fields are flattened into `HostBinding` / `QueueEntry` on the wire /// (so existing JSON stays compatible via `#[serde(default)]`); this type /// is the in-code bundle the boot chain consumes. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct DeployProfile { /// Hostname to set on the imaged machine (`{{HOSTNAME}}`). Empty/None /// leaves the installer default. #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_hostname: Option, /// Static IPv4/IPv6 the installer should configure (`{{IP}}`). Stored /// as a free-form string — validated lightly at the HTTP layer. #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_ip: Option, /// Id of an uploaded file in the unattended store. Empty/None means /// "no unattended install — boot interactively". #[serde(default, skip_serializing_if = "Option::is_none")] pub unattended_file: Option, } /// Cap on the stored hostname / IP strings — generous for any real value /// but bounds what an operator can stuff into the JSON. pub const MAX_PROFILE_FIELD_LEN: usize = 255; impl DeployProfile { /// True when nothing is set — lets call sites skip work entirely. #[must_use] pub fn is_empty(&self) -> bool { self.auto_hostname.is_none() && self.auto_ip.is_none() && self.unattended_file.is_none() } /// True when an unattended file is selected (drives boot-chain injection). #[must_use] pub fn has_unattended(&self) -> bool { self.unattended_file .as_deref() .is_some_and(|s| !s.trim().is_empty()) } /// Normalise: trim every field and collapse empty strings to `None` /// so persisted JSON never carries `""` for an unset value. #[must_use] pub fn normalized(mut self) -> Self { fn clean(v: Option) -> Option { v.map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .map(|s| s.chars().take(MAX_PROFILE_FIELD_LEN).collect()) } self.auto_hostname = clean(self.auto_hostname); self.auto_ip = clean(self.auto_ip); self.unattended_file = clean(self.unattended_file); self } } #[cfg(test)] mod tests { use super::*; #[test] fn empty_profile_is_empty() { assert!(DeployProfile::default().is_empty()); assert!(!DeployProfile::default().has_unattended()); } #[test] fn normalize_trims_and_nulls_empty() { let p = DeployProfile { auto_hostname: Some(" node-7 ".into()), auto_ip: Some(" ".into()), unattended_file: Some(String::new()), } .normalized(); assert_eq!(p.auto_hostname.as_deref(), Some("node-7")); assert_eq!(p.auto_ip, None); assert_eq!(p.unattended_file, None); assert!(!p.is_empty()); } #[test] fn has_unattended_detects_real_id() { let p = DeployProfile { unattended_file: Some("ubuntu-ks".into()), ..Default::default() }; assert!(p.has_unattended()); } #[test] fn long_field_is_capped() { let long = "a".repeat(1000); let p = DeployProfile { auto_hostname: Some(long), ..Default::default() } .normalized(); assert_eq!( p.auto_hostname.as_deref().map(str::len), Some(MAX_PROFILE_FIELD_LEN) ); } }