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]>
291 lines
9.9 KiB
Rust
291 lines
9.9 KiB
Rust
//! Queued Deployment queue.
|
|
//!
|
|
//! When a client selects "Queued Deployment" at the PXE menu, iPXE POSTs to
|
|
//! `/api/queue/join` and receives a queue position. It then enters a poll
|
|
//! loop hitting `/api/queue/poll/<id>`; the server holds the request open
|
|
//! until either (a) the operator assigns an ISO from the WebUI, in which
|
|
//! case the poll returns an iPXE `chain` URL, or (b) the poll times out
|
|
//! (iPXE's HTTP client has its own timeout), in which case iPXE re-POSTs.
|
|
//!
|
|
//! The WebUI shows the queue (`GET /api/queue`) and issues
|
|
//! `POST /api/queue/assign { iso_id, entry_ids: [...] }` to launch a single
|
|
//! ISO across many queued clients at once. Every waiting machine receives
|
|
//! the assignment without operator visits at the rack.
|
|
|
|
use parking_lot::RwLock;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::net::IpAddr;
|
|
use std::sync::Arc;
|
|
use time::OffsetDateTime;
|
|
use tokio::sync::Notify;
|
|
use uuid::Uuid;
|
|
|
|
use crate::profile::DeployProfile;
|
|
use crate::ClientArch;
|
|
|
|
/// Per-client queue state visible to the WebUI.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct QueueEntry {
|
|
pub id: String,
|
|
/// 1-based queue position — position 1 is whoever got there first.
|
|
pub position: u32,
|
|
pub mac: String,
|
|
pub ip: Option<IpAddr>,
|
|
pub arch: Option<ClientArch>,
|
|
#[serde(with = "time::serde::rfc3339")]
|
|
pub joined_at: OffsetDateTime,
|
|
#[serde(with = "time::serde::rfc3339")]
|
|
pub last_poll_at: OffsetDateTime,
|
|
pub assigned_target: Option<String>,
|
|
/// v0.5.2: optional per-device deployment profile set via the queue
|
|
/// "Profile" button (auto hostname / IP / unattended file). Flattened
|
|
/// so the JSON stays flat alongside the other queue fields.
|
|
#[serde(default, flatten)]
|
|
pub profile: DeployProfile,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct QueueEntryInner {
|
|
id: String,
|
|
position: u32,
|
|
mac: String,
|
|
ip: Option<IpAddr>,
|
|
arch: Option<ClientArch>,
|
|
joined_at: OffsetDateTime,
|
|
last_poll_at: OffsetDateTime,
|
|
assigned_target: Option<String>,
|
|
profile: DeployProfile,
|
|
/// Broadcast primitive that wakes the long-poll as soon as an
|
|
/// assignment lands — no polling on our side, no sleep-loops.
|
|
notify: Arc<Notify>,
|
|
}
|
|
|
|
impl QueueEntryInner {
|
|
fn snapshot(&self) -> QueueEntry {
|
|
QueueEntry {
|
|
id: self.id.clone(),
|
|
position: self.position,
|
|
mac: self.mac.clone(),
|
|
ip: self.ip,
|
|
arch: self.arch,
|
|
joined_at: self.joined_at,
|
|
last_poll_at: self.last_poll_at,
|
|
assigned_target: self.assigned_target.clone(),
|
|
profile: self.profile.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct DeploymentQueue {
|
|
inner: RwLock<HashMap<String, QueueEntryInner>>,
|
|
}
|
|
|
|
impl DeploymentQueue {
|
|
#[must_use]
|
|
pub fn new() -> Arc<Self> {
|
|
Arc::new(Self::default())
|
|
}
|
|
|
|
/// Add a client to the queue. Returns the current queue snapshot. If the
|
|
/// MAC is already queued, the existing entry is returned unchanged —
|
|
/// retrying iPXE clients don't duplicate their slot.
|
|
pub fn join(&self, mac: &str, ip: Option<IpAddr>, arch: Option<ClientArch>) -> QueueEntry {
|
|
let now = OffsetDateTime::now_utc();
|
|
let mut guard = self.inner.write();
|
|
|
|
if let Some(existing) = guard.values_mut().find(|g| g.mac == mac) {
|
|
existing.last_poll_at = now;
|
|
if ip.is_some() {
|
|
existing.ip = ip;
|
|
}
|
|
if arch.is_some() {
|
|
existing.arch = arch;
|
|
}
|
|
return existing.snapshot();
|
|
}
|
|
|
|
// Queue position = max(position) + 1, or 1 if empty.
|
|
let next_pos = guard.values().map(|g| g.position).max().unwrap_or(0) + 1;
|
|
let id = Uuid::new_v4().to_string();
|
|
let inner = QueueEntryInner {
|
|
id: id.clone(),
|
|
position: next_pos,
|
|
mac: mac.to_string(),
|
|
ip,
|
|
arch,
|
|
joined_at: now,
|
|
last_poll_at: now,
|
|
assigned_target: None,
|
|
profile: DeployProfile::default(),
|
|
notify: Arc::new(Notify::new()),
|
|
};
|
|
let snap = inner.snapshot();
|
|
guard.insert(id, inner);
|
|
snap
|
|
}
|
|
|
|
/// Look up the `Notify` primitive for a given queue entry id, for long-polling.
|
|
#[must_use]
|
|
pub fn notifier(&self, entry_id: &str) -> Option<Arc<Notify>> {
|
|
self.inner.read().get(entry_id).map(|g| g.notify.clone())
|
|
}
|
|
|
|
/// Update the last-poll timestamp (keeps the queue's "live" indicator
|
|
/// fresh in the UI) and return the current snapshot. Returns None if
|
|
/// the entry was released/expired between requests.
|
|
pub fn touch(&self, entry_id: &str) -> Option<QueueEntry> {
|
|
let mut guard = self.inner.write();
|
|
let g = guard.get_mut(entry_id)?;
|
|
g.last_poll_at = OffsetDateTime::now_utc();
|
|
Some(g.snapshot())
|
|
}
|
|
|
|
/// Operator sets (or clears) the deployment profile for a queued
|
|
/// device via the WebUI "Profile" button. Returns the updated
|
|
/// snapshot, or `None` if the entry has since been released.
|
|
pub fn set_profile(&self, entry_id: &str, profile: DeployProfile) -> Option<QueueEntry> {
|
|
let mut guard = self.inner.write();
|
|
let g = guard.get_mut(entry_id)?;
|
|
g.profile = profile.normalized();
|
|
Some(g.snapshot())
|
|
}
|
|
|
|
/// Look up the deployment profile for a queued MAC, if any. Used by
|
|
/// the boot chain to inject an unattended file / template the
|
|
/// hostname + IP when an assigned device chains to its target.
|
|
#[must_use]
|
|
pub fn profile_for_mac(&self, mac: &str) -> Option<DeployProfile> {
|
|
let guard = self.inner.read();
|
|
guard
|
|
.values()
|
|
.find(|g| g.mac == mac)
|
|
.map(|g| g.profile.clone())
|
|
.filter(|p| !p.is_empty())
|
|
}
|
|
|
|
/// Operator assigns an ISO entry (boot_entry id) to one or more clients.
|
|
/// Returns the number of queue entries that were updated. Entries not in the
|
|
/// queue are silently skipped.
|
|
pub fn assign(&self, entry_ids: &[String], target: &str) -> usize {
|
|
let mut guard = self.inner.write();
|
|
let mut updated = 0;
|
|
for id in entry_ids {
|
|
if let Some(g) = guard.get_mut(id) {
|
|
g.assigned_target = Some(target.to_string());
|
|
g.notify.notify_waiters();
|
|
updated += 1;
|
|
}
|
|
}
|
|
updated
|
|
}
|
|
|
|
/// Remove a queue entry and return its final snapshot. Called after the client
|
|
/// has successfully chained onto its assignment.
|
|
pub fn release(&self, entry_id: &str) -> Option<QueueEntry> {
|
|
let mut guard = self.inner.write();
|
|
let g = guard.remove(entry_id)?;
|
|
g.notify.notify_waiters();
|
|
// Renumber positions so the display stays contiguous (1..N). This
|
|
// is O(N) but the queue is expected to be small (dozens of hosts).
|
|
let mut remaining: Vec<_> = guard.values_mut().collect();
|
|
remaining.sort_by_key(|g| g.position);
|
|
for (i, g) in remaining.iter_mut().enumerate() {
|
|
g.position = (i + 1) as u32;
|
|
}
|
|
Some(g.snapshot())
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn list(&self) -> Vec<QueueEntry> {
|
|
let guard = self.inner.read();
|
|
let mut v: Vec<_> = guard.values().map(QueueEntryInner::snapshot).collect();
|
|
v.sort_by_key(|g| g.position);
|
|
v
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn len(&self) -> usize {
|
|
self.inner.read().len()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.len() == 0
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn join_assigns_sequential_positions() {
|
|
let q = DeploymentQueue::new();
|
|
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
|
|
let g2 = q.join("aa:bb:cc:00:00:02", None, None);
|
|
let g3 = q.join("aa:bb:cc:00:00:03", None, None);
|
|
assert_eq!(g1.position, 1);
|
|
assert_eq!(g2.position, 2);
|
|
assert_eq!(g3.position, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn rejoining_same_mac_is_idempotent() {
|
|
let q = DeploymentQueue::new();
|
|
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
|
|
let g2 = q.join("aa:bb:cc:00:00:01", None, None);
|
|
assert_eq!(g1.id, g2.id);
|
|
assert_eq!(g1.position, g2.position);
|
|
assert_eq!(q.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn assign_broadcasts_target() {
|
|
let q = DeploymentQueue::new();
|
|
let g1 = q.join("aa:bb:cc:00:00:01", None, None);
|
|
let g2 = q.join("aa:bb:cc:00:00:02", None, None);
|
|
let n = q.assign(&[g1.id.clone(), g2.id.clone()], "ubuntu-24-04-linux");
|
|
assert_eq!(n, 2);
|
|
for g in q.list() {
|
|
assert_eq!(g.assigned_target.as_deref(), Some("ubuntu-24-04-linux"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn release_renumbers() {
|
|
let q = DeploymentQueue::new();
|
|
let a = q.join("aa:00:00:00:00:01", None, None);
|
|
let _b = q.join("aa:00:00:00:00:02", None, None);
|
|
let c = q.join("aa:00:00:00:00:03", None, None);
|
|
q.release(&a.id);
|
|
let list = q.list();
|
|
assert_eq!(list.len(), 2);
|
|
assert_eq!(list[0].position, 1);
|
|
assert_eq!(list[1].position, 2);
|
|
// c had position 3, now renumbered to 2.
|
|
assert_eq!(list[1].id, c.id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn assign_wakes_waiter() {
|
|
let q = DeploymentQueue::new();
|
|
let g = q.join("aa:00:00:00:00:01", None, None);
|
|
let notify = q.notifier(&g.id).unwrap();
|
|
|
|
let q2 = Arc::new(q);
|
|
let q3 = q2.clone();
|
|
let id = g.id.clone();
|
|
let fut = tokio::spawn(async move {
|
|
notify.notified().await;
|
|
q3.touch(&id)
|
|
});
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
q2.assign(std::slice::from_ref(&g.id), "x");
|
|
let result = fut.await.unwrap();
|
|
assert!(result.is_some());
|
|
assert_eq!(result.unwrap().assigned_target.as_deref(), Some("x"));
|
|
}
|
|
}
|