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:
Miles Ward
2026-05-06 14:13:38 -04:00
parent c607f2e31c
commit e3452fe976
56 changed files with 755 additions and 802 deletions
+31 -31
View File
@@ -3,7 +3,7 @@
//! Spins up the real axum router against a temp ISO store + settings store,
//! then walks an imaginary iPXE client through: dashboard status → upload
//! ISO → fetch top-level boot menu → fetch per-entry script → Range-GET the
//! ISO. Also drives the Gated Deployment flow end-to-end: two clients join,
//! ISO. Also drives the Queued Deployment flow end-to-end: two clients join,
//! operator assigns, both polls return the chain script with retry fallback.
//!
//! This is the closest we can get to "real PXE client" without QEMU; the
@@ -12,9 +12,9 @@
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use pxeforge_core::{ClientRegistry, GateQueue, HostBindings, LogBus, Metrics, SettingsStore};
use pxeforge_http_api::{build_router, AppState};
use pxeforge_iso_store::{IsoStore, NfsManager};
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
use openpxe_http_api::{build_router, AppState};
use openpxe_iso_store::{IsoStore, NfsManager};
use tempfile::tempdir;
use tower::ServiceExt;
@@ -87,7 +87,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let iso_store = IsoStore::new(dir.path().join("isos"));
iso_store.ensure_dirs().await.unwrap();
let clients = ClientRegistry::new();
let gates = GateQueue::new();
let gates = DeploymentQueue::new();
let settings = SettingsStore::load_or_default(dir.path());
let nfs = NfsManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
@@ -97,7 +97,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let state = AppState {
iso_store,
clients,
gates,
queue: gates,
settings,
hosts,
metrics,
@@ -198,7 +198,7 @@ async fn iso_range_request_slices_correctly() {
}
#[tokio::test]
async fn gated_deployment_full_flow() {
async fn queued_deployment_full_flow() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
@@ -210,22 +210,22 @@ async fn gated_deployment_full_flow() {
.await.unwrap();
// Two clients join.
let (_, join1) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:01").await;
let (_, join2) = get(&app, "/api/gate/join?mac=aa:bb:cc:00:00:02").await;
let (_, join1) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:01").await;
let (_, join2) = get(&app, "/api/queue/join?mac=aa:bb:cc:00:00:02").await;
let s1 = String::from_utf8(join1).unwrap();
let s2 = String::from_utf8(join2).unwrap();
assert!(s1.contains("Gate Position 1"));
assert!(s2.contains("Gate Position 2"));
let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/"))
let gate1_id = s1.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
.unwrap().to_string();
let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/gate/poll/"))
let gate2_id = s2.lines().find_map(|l| l.strip_prefix("chain http://127.0.0.1/api/queue/poll/"))
.unwrap().to_string();
// Kick off a long-poll for client 1 in the background. Then assign.
let app2 = app.clone();
let poll_future = tokio::spawn(async move {
let uri = format!("/api/gate/poll/{gate1_id}");
let uri = format!("/api/queue/poll/{gate1_id}");
get(&app2, &uri).await
});
@@ -233,15 +233,15 @@ async fn gated_deployment_full_flow() {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Operator assigns.
let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":["{gate2_id}"]}}"#);
let (s, b) = post_json(&app, "/api/gate/assign", &body).await;
let body = format!(r#"{{"target":"fake-alpine-linux","entry_ids":["{gate2_id}"]}}"#);
let (s, b) = post_json(&app, "/api/queue/assign", &body).await;
assert_eq!(s, StatusCode::OK);
let assign_json = String::from_utf8(b).unwrap();
assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}");
// Now assign to gate 1 too so the background poll wakes.
let body = r#"{"target":"fake-alpine-linux","gate_ids":[]}"#;
post_json(&app, "/api/gate/assign", body).await;
let body = r#"{"target":"fake-alpine-linux","entry_ids":[]}"#;
post_json(&app, "/api/queue/assign", body).await;
let (poll_status, poll_body) = poll_future.await.unwrap();
assert_eq!(poll_status, StatusCode::OK);
@@ -251,12 +251,12 @@ async fn gated_deployment_full_flow() {
"poll response should chain the boot script:\n{poll_s}"
);
// Retry-on-error fallback must be present.
assert!(poll_s.contains("|| chain http://127.0.0.1/api/gate/poll/"),
assert!(poll_s.contains("|| chain http://127.0.0.1/api/queue/poll/"),
"retry fallback missing");
// Bad target must be rejected.
let (_, bad) = post_json(&app, "/api/gate/assign",
r#"{"target":"does-not-exist","gate_ids":[]}"#).await;
let (_, bad) = post_json(&app, "/api/queue/assign",
r#"{"target":"does-not-exist","entry_ids":[]}"#).await;
let bad_s = String::from_utf8(bad).unwrap();
assert!(bad_s.contains(r#""ok":false"#), "expected rejection: {bad_s}");
}
@@ -329,7 +329,7 @@ async fn ui_assets_served_offline() {
("/assets/app.js", "application/javascript"),
("/assets/app.css", "text/css"),
("/assets/logo.svg", "image/svg+xml"),
("/assets/anvil-forge.svg", "image/svg+xml"),
("/assets/loader.svg", "image/svg+xml"),
] {
let res = app
.clone()
@@ -406,14 +406,14 @@ async fn terminal_help_and_status_round_trip() {
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":""}"#).await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
assert!(v["output"].as_str().unwrap().contains("PXEForge terminal"));
assert!(v["output"].as_str().unwrap().contains("OpenPXE terminal"));
// status -> contains the version banner.
let (s, b) = post_json(&app, "/api/terminal", r#"{"command":"status"}"#).await;
assert_eq!(s, StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&b).unwrap();
let out = v["output"].as_str().unwrap();
assert!(out.starts_with("PXEForge"), "unexpected status output: {out}");
assert!(out.starts_with("OpenPXE"), "unexpected status output: {out}");
assert!(out.contains("isos:"), "status missing iso line: {out}");
// Unknown command -> ok=false plus help hint.
@@ -477,7 +477,7 @@ async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
.uri("/api/settings")
.header("content-type", "application/json")
.body(Body::from(
r#"{"boot_menu_timeout_secs":600,"timeout_action":"gated_deployment",
r#"{"boot_menu_timeout_secs":600,"timeout_action":"queued_deployment",
"windows_enabled":false,"smb_host_override":"","extra_kernel_args":"",
"default_local_hdd":true,"gate_wait_max_secs":0,"dns_server":""}"#
.to_string(),
@@ -623,19 +623,19 @@ async fn metrics_endpoint_emits_prometheus_format() {
let body = String::from_utf8(body.to_vec()).unwrap();
// Spot-check the must-have metric families.
for name in [
"pxeforge_dhcp_replies_total",
"pxeforge_tftp_transfers_total",
"pxeforge_http_requests_total",
"pxeforge_iso_count",
"pxeforge_uptime_seconds",
"pxeforge_build_info",
"openpxe_dhcp_replies_total",
"openpxe_tftp_transfers_total",
"openpxe_http_requests_total",
"openpxe_iso_count",
"openpxe_uptime_seconds",
"openpxe_build_info",
] {
assert!(body.contains(name), "missing metric {name} in:\n{body}");
}
// Each name appears exactly once as a `# TYPE` declaration.
for name in [
"pxeforge_dhcp_replies_total",
"pxeforge_iso_count",
"openpxe_dhcp_replies_total",
"openpxe_iso_count",
] {
let count = body.matches(&format!("# TYPE {name}")).count();
assert_eq!(count, 1, "{name} TYPE line appears {count} times");