//! Queued Deployment queue. //! //! When a client selects "Queued Deployment" at the PXE menu, iPXE POSTs to //! `/api/queue/join` and receives a gate position. It then enters a poll //! loop hitting `/api/queue/poll/`; 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/gate`) and issues //! `POST /api/queue/assign { iso_id, entry_ids: [...] }` to launch a single //! ISO across many gated clients at once. This is the "horse-race gate" //! UX the user asked for — every horse leaves the line simultaneously. 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::ClientArch; /// Per-gate state visible to the WebUI. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Gate { pub id: String, /// 1-based race-gate position — position 1 is whoever got there first. pub position: u32, pub mac: String, pub ip: Option, pub arch: Option, #[serde(with = "time::serde::rfc3339")] pub joined_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339")] pub last_poll_at: OffsetDateTime, pub assigned_target: Option, } #[derive(Debug)] struct QueueEntryInner { id: String, position: u32, mac: String, ip: Option, arch: Option, joined_at: OffsetDateTime, last_poll_at: OffsetDateTime, assigned_target: Option, /// Broadcast primitive that wakes the long-poll as soon as an /// assignment lands — no polling on our side, no sleep-loops. notify: Arc, } impl QueueEntryInner { fn snapshot(&self) -> Gate { Gate { 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(), } } } #[derive(Debug, Default)] pub struct DeploymentQueue { inner: RwLock>, } impl DeploymentQueue { #[must_use] pub fn new() -> Arc { Arc::new(Self::default()) } /// Add a client to the gate. Returns the new `Gate` snapshot. If the /// MAC is already queued, the existing gate is returned unchanged — /// retrying iPXE clients don't duplicate their slot. pub fn join(&self, mac: &str, ip: Option, arch: Option) -> Gate { 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(); } // Race 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, notify: Arc::new(Notify::new()), }; let snap = inner.snapshot(); guard.insert(id, inner); snap } /// Look up the `Notify` primitive for a given gate id, for long-polling. #[must_use] pub fn notifier(&self, entry_id: &str) -> Option> { self.inner.read().get(entry_id).map(|g| g.notify.clone()) } /// Update the last-poll timestamp (keeps the gate's "live" indicator /// fresh in the UI) and return the current snapshot. Returns None if /// the gate was released/expired between requests. pub fn touch(&self, entry_id: &str) -> Option { let mut guard = self.inner.write(); let g = guard.get_mut(entry_id)?; g.last_poll_at = OffsetDateTime::now_utc(); Some(g.snapshot()) } /// Operator assigns an ISO entry (boot_entry id) to one or more gates. /// Returns the number of gates that were updated. Gates 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 gate and return its final snapshot. Called after the client /// has successfully chained onto its assignment. pub fn release(&self, entry_id: &str) -> Option { 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 { 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")); } }