Authentication / login:
- Separate the local username/password form from the SSO "Sign in with …"
button (FleetDM-style divider + optional IdP logo); credential fields no
longer double as the SSO trigger. Settings → SSO copy now says SAML is live.
Branding — three slots (light / dark / client) on one row:
- Light/Dark feed the top-left mark + sign-in page by active theme (with
cross-theme fallback; theme toggle swaps the logo live). Client feeds the
PXE boot-menu background. Favicon pinned to the bundled mark via a new
/assets/favicon.svg endpoint. Legacy single logo migrates to dark + client.
- BrandingStore refactored to per-slot storage; /api/branding/logo/:slot.
Unattended installs (Storage → Advanced):
- New UnattendedStore (iso-store) + /api/unattended upload/list/delete and a
public templated serve at /unattended/:id (+ NoCloud seed dir for
autoinstall). Accepts .ks/.cfg/.seed/.yaml/.yml/.xml/user-data; classified
on upload; stored in its own unattended/ dir, never the ISO listing/menu.
- {{HOSTNAME}}/{{IP}}/{{MAC}} substituted per host at serve time.
Host pins + Queue profiles:
- HostBinding + QueueEntry carry an optional DeployProfile (auto_hostname /
auto_ip / unattended_file). Hosts pin form + a per-device Queue "Profile"
button collect them. On boot, a matched MAC has the right kernel arg
injected (inst.ks= / preseed url= / autoinstall ds=nocloud-net) and the
hostname/IP templated into the served answer file. DHCP stays proxy-only.
Storage:
- Remote shares default protocol is now NFS; updated descriptive copy.
235 tests green, clippy clean. Still a single static musl binary, pure Rust.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
123 lines
4.4 KiB
Rust
123 lines
4.4 KiB
Rust
//! 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<String>,
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<String>) -> Option<String> {
|
|
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)
|
|
);
|
|
}
|
|
}
|