v0.3.0 — rebrand: PXEForge → OpenPXE, Gated → Queued Deployment
Full rename to match the openpxe.com brand. The product now reads as a
polished open-source project rather than a personal-tool nickname:
the anvil/forge metaphor is gone, replaced with the rainbow-horizon
brand mark from the marketing site.
## Naming changes
**PXEForge → OpenPXE** everywhere it's user-visible or developer-
facing:
- All 8 crate package names (`pxeforge-*` → `openpxe-*`).
- The bin crate dir + binary (`crates/pxeforge` → `crates/openpxe`,
`bin = "openpxe"`).
- Env vars: `PXEFORGE_*` → `OPENPXE_*` (no compat shim — pre-beta).
- Tracing targets: `pxeforge::*` → `openpxe::*`.
- Prometheus metrics: `pxeforge_*` → `openpxe_*` (pre-beta; nobody
has dashboards on these yet).
- Container image: `gitea.milesward.dev/mward4/openpxe:0.3.0`.
- All in-tree paths: `/var/lib/openpxe/{isos,work,smb}`,
`/usr/share/openpxe/ipxe`, `/etc/openpxe/...`.
- Unraid template renamed `pxeforge.xml` → `openpxe.xml`.
- README, NEXT_PHASE.md, architecture.md, comments, and the WebUI
brand string.
**Gated Deployment → Queued Deployment** as the user-facing concept:
- `Settings::TimeoutAction::GatedDeployment` →
`QueuedDeployment` (with `#[serde(alias = "gated_deployment")]`
so v0.2.0 settings.json files keep deserializing).
- Rust types: `Gate` → `QueueEntry`, `GateQueue` → `DeploymentQueue`,
`GateInner` → `QueueEntryInner`.
- File: `crates/core/src/gate.rs` → `crates/core/src/queue.rs`.
- HTTP routes: `/api/gate/*` → `/api/queue/*`. The JSON list key
flipped from `"gates"` to `"entries"` to match.
- iPXE shortcut: `/boot/_gate.ipxe` → `/boot/_queue.ipxe`. The
top-level menu's item id is now `queue` instead of `gate`.
- WebUI sidebar tab: "Forge Gate" → "Queue".
- Field on `AppState`: `gates` → `queue`.
## Brand assets
The anvil + forging-sparks logos are dropped:
- `logo.svg` is now a 24×24 medallion filled with the
`rainbow-horizon` gradient from openpxe.com (sliding hue rotation
via SMIL on the gradient stops, no JS needed).
- `anvil-forge.svg` renamed to `loader.svg` and rebuilt as a 64×64
louder version of the same disc — used for page-load transitions
and the imaging-progress widget. Adds a subtle scale pulse and a
white inner-glow so it has dimensionality on either theme.
## CSS rename
- `.forge-progress` → `.queue-progress`
- `.forge-progress .anvil` → `.queue-progress .mark`
- `@keyframes forge-sheen` → `queue-sheen`
- `.loader .anvil` → `.loader .mark`
- "Heating the forge…" loader text → "Loading…"
The rest of the layout is untouched. Light/dark theme tokens and the
sidebar/topbar structure carry over from v0.2.0 unchanged — the
brief was "keeping the UI similar."
## Validation
- `cargo build --workspace` — clean.
- `cargo clippy --workspace --all-targets` — no warnings.
- `cargo test --workspace` — **66 tests passing**, same as v0.2.0.
- Local smoke run against the rebuilt release binary verifies:
- `/boot.ipxe` emits `Queued Deployment` + `item queue` + chains
`/boot/_queue.ipxe`
- `/api/queue` returns `{count, entries}`
- `/metrics` emits `openpxe_queue_count` (renamed)
- `/assets/logo.svg` and `/assets/loader.svg` serve the new
rainbow brand SVGs
- `/api/status` reports version `0.3.0`
## Migration notes for operators on v0.2.0
- Container image path changed: pull
`gitea.milesward.dev/mward4/openpxe:0.3.0` (not `pxeforge:`).
- Bind mounts: `/var/lib/openpxe/{isos,work,smb}` (not `pxeforge`).
Move the host path or update the template.
- Env vars: replace `PXEFORGE_*` with `OPENPXE_*`. The Unraid
template at `deploy/unraid/openpxe.xml` is already updated.
- `settings.json` carries over transparently — the
`gated_deployment` value is accepted as an alias.
- HTTP API: any external scripts that hit `/api/gate/*` need to
switch to `/api/queue/*`. The JSON envelope key is `entries`
instead of `gates`.
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
//! 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/<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/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<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>,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
/// 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) -> 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<HashMap<String, QueueEntryInner>>,
|
||||
}
|
||||
|
||||
impl DeploymentQueue {
|
||||
#[must_use]
|
||||
pub fn new() -> Arc<Self> {
|
||||
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<IpAddr>, arch: Option<ClientArch>) -> 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<Arc<Notify>> {
|
||||
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<Gate> {
|
||||
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<Gate> {
|
||||
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<Gate> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user