v0.2.0 — pre-beta: per-MAC bindings, /metrics, themes, animated forge

This is the bulk pre-beta cleanup pass. Bumps the workspace to 0.2.0.
Test count is 56 -> 66 (+10), clippy is fully clean across the
workspace (was several dozen warnings).

## New features

**Per-MAC host bindings** (Tinkerbell smee pattern). New
`HostBindings` registry maps a MAC -> preferred boot target, persisted
to <work_dir>/hosts.json. The DHCP reply now embeds `?mac=${mac}` in
the boot.ipxe URL; iPXE substitutes the literal MAC client-side, so
the HTTP layer can short-circuit straight to the bound target instead
of rendering the menu. Reserved menu shortcuts (`_local`, `_gate`,
`_tools_menu`) are valid targets too. New /api/hosts CRUD + a Hosts
tab in the sidebar.

**Prometheus `/metrics`** endpoint. Tiny lock-free implementation —
just AtomicU64s and a Display impl, no `prometheus` / `metrics-rs`
dep. Counters: DHCP replies (per arch label), DHCP declined, TFTP
transfers (per status), TFTP bytes, HTTP requests (per route).
Gauges: ISO count, client count, gate count, gate-imaging, NFS active
mounts, uptime, build info. Plain text exposition format,
text/plain;version=0.0.4 content-type, no auth (all metric values are
non-sensitive counts).

**Light + dark themes**. CSS tokens on `:root` and
`:root[data-theme=light]`, swap by toggle button (top-right) or `T`
hotkey. Persisted in localStorage; pre-paint inline script avoids
dark<->light flash. Light palette designed against the Netbox Labs
reference screenshot — near-white surfaces, soft grey dividers,
accent unchanged for brand consistency. Terminal pane stays dark in
both themes (it's a console, that's the right read).

**Animated SVG logo + forge widget**. New `logo.svg` is a refined
silver/grey anvil. New `anvil-forge.svg` adds rising sparks and a
pulsing underglow via SMIL — pure SVG, no GIF, no JS animation loop.
Used:
  - in the **forge progress** widget on Dashboard + Forge Gate, paired
    with a `linear-gradient(warn -> accent)` bar with a moving sheen;
    goes idle (greyscale, no sheen) at zero imaging load
  - in the page-load `<div class=loader>` that replaces the old
    "Loading..." text

## Code cleanup pass

`cargo clippy --workspace --all-targets` is now warning-free. Spot
fixes across the tree:
  - `format!()`-into-`String` -> `std::fmt::Write::write!`
  - manual reverse comparators -> `Reverse`
  - `map_or(false, ...)` -> `is_some_and`
  - redundant closures -> method references
  - `r#"..."#` raw strings without `"` -> `r"..."`
  - `std::io::Error::new(Other, ...)` -> `Error::other`
  - `as i32` on `c.id()` -> `cast_signed()`
  - merged identical match arms

## Windows workflow validation

New integration test synthesizes an ISO9660 with the SOURCES\\BOOT.WIM
sentinel, uploads it, asserts:
  1. introspection labels it `windows_pe` with has_boot_wim=true,
  2. the boot entry is `BootKind::Wimboot` with all five canonical
     files (bootmgr, bootmgr.efi, bcd, boot.sdi, boot.wim),
  3. the rendered iPXE script chains wimboot with `initrd --name`
     entries for each file, and
  4. NO trust-store strings appear in the rendered output: bcdedit,
     testsigning, certutil, httpdisk, and test-signed are all
     explicitly forbidden as a hard guarantee.

WinPE bootstrap (startnet.cmd) picks up the Bootimus v0.1.58 lessons:
explicit `net start Workstation` before `net use` to avoid the SMB
client lazy-init race, and surfaces errors instead of blind retries.

## Docs

architecture.md gains a "Phase 5" section explaining the host-bindings
+ metrics + theming + Windows-test work, plus a refreshed "deferred
to Phase 6" list (real-hardware integration, autounattend library,
distro profile manifest, WoL trigger, syslog receiver, IPv6).
README updates the status line, the "what it does" list, and adds
the new Hosts/Terminal tab names.
This commit is contained in:
Miles Ward
2026-04-30 02:28:10 -04:00
parent 083277faae
commit 6d3d636fad
31 changed files with 1651 additions and 209 deletions
+159 -4
View File
@@ -45,6 +45,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/assets/app.js", get(ui_js))
.route("/assets/app.css", get(ui_css))
.route("/assets/logo.svg", get(ui_logo))
.route("/assets/anvil-forge.svg", get(ui_anvil_forge))
// iPXE script endpoints.
.route("/boot.ipxe", get(boot_top_menu))
.route("/boot/:filename", get(boot_sub))
@@ -80,6 +81,14 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/log/clear", post(log_stream::clear))
// Phase 4: operator terminal commands (whitelisted).
.route("/api/terminal", post(terminal::run_command))
// Phase 5: per-MAC host bindings (Tinkerbell-style). Operator
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
.route("/api/hosts/:mac", delete(api_hosts_remove))
// Phase 5: Prometheus scrape endpoint. Plain text exposition
// format. No auth — the metrics surface is intentionally
// boring (counts, no payloads).
.route("/metrics", get(api_metrics))
.layer(TraceLayer::new_for_http())
// 16 GiB upload cap — ISOs are big; chunks stream so this isn't memory use.
.layer(DefaultBodyLimit::max(16 * 1024 * 1024 * 1024))
@@ -115,6 +124,13 @@ async fn ui_logo() -> Response {
).into_response()
}
async fn ui_anvil_forge() -> Response {
(
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
pxeforge_webui::anvil_forge_svg(),
).into_response()
}
// ─── iPXE scripts ──────────────────────────────────────────────────────────
fn text_plain(body: String) -> Response {
@@ -122,10 +138,51 @@ fn text_plain(body: String) -> Response {
.into_response()
}
async fn boot_top_menu(State(state): State<AppState>) -> Response {
/// Top-level boot script. Honors per-MAC host bindings: if the
/// requesting client carries a `?mac=...` query param (iPXE's `${mac}`
/// substitution) and that MAC has a binding, we short-circuit straight
/// to the bound target instead of rendering the menu.
async fn boot_top_menu(
State(state): State<AppState>,
Query(p): Query<BootMenuParams>,
) -> Response {
state.metrics.record_http(pxeforge_core::HttpRoute::BootScript);
let isos = state.iso_store.list();
let settings = state.settings.snapshot();
text_plain(render_menu(&isos, &settings, &state.public_base_url))
let base = &state.public_base_url;
// Per-MAC override: if the client identified itself and we have a
// binding, chain directly. The chain target falls back to the menu
// on failure so a stale / misconfigured binding can't lock a client
// out — it just shows the menu.
if let Some(mac) = p.mac.as_deref() {
if let Some(binding) = state.hosts.lookup(mac) {
tracing::info!(
target: "pxeforge::http",
mac = %binding.mac, target = %binding.target,
"host binding applied"
);
let target = binding.target;
// Reserved menu shortcuts are emitted as `_xxx`; per-entry
// boot scripts are at `/boot/<id>.ipxe`. Both share the same
// `/boot/<name>` route, so the URL is identical.
return text_plain(format!(
"#!ipxe\n\
echo PXEForge: per-MAC binding -> {target}\n\
chain {base}/boot/{target}.ipxe || chain {base}/boot.ipxe\n"
));
}
}
text_plain(render_menu(&isos, &settings, base))
}
#[derive(Debug, Deserialize)]
struct BootMenuParams {
/// Client MAC, supplied by iPXE via `${mac}` variable in
/// `chain ${prefix}/boot.ipxe?mac=${mac}`. Optional — if absent we
/// fall back to the menu unconditionally.
mac: Option<String>,
}
async fn boot_sub(
@@ -204,7 +261,7 @@ async fn iso_file(
return (StatusCode::NOT_FOUND, "no such iso").into_response();
};
let p = iso_path.clone();
let in_path = format!("/{}", path);
let in_path = format!("/{path}");
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
.await.ok().flatten();
let Some(loc) = loc else {
@@ -362,18 +419,27 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
let nfs = state.nfs.list();
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
let isos = state.iso_store.list();
let clients = state.clients.list();
let gates = state.gates.list();
// Phase 4: dashboard tracks "imaging" as gates with an assignment
// already issued — they're the ones actively chaining a boot script.
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
let waiting = gates.len() - imaging;
// Side-effect: push gauge values out to the Prometheus surface.
// Doing it here (in the most-frequently-polled endpoint) keeps the
// gauges fresh without a dedicated scrape-time hook.
state.metrics.set_iso_count(isos.len() as u64);
state.metrics.set_client_count(clients.len() as u64);
state.metrics.set_gate_counts(gates.len() as u64, imaging as u64);
state.metrics.set_nfs_active(nfs_active as u64);
state.metrics.record_http(pxeforge_core::HttpRoute::Api);
let now = time::OffsetDateTime::now_utc();
let uptime_secs = (now - state.started_at).whole_seconds().max(0);
Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"public_base_url": state.public_base_url,
"iso_count": isos.len(),
"client_count": state.clients.list().len(),
"client_count": clients.len(),
"gate_count": gates.len(),
"imaging_count": imaging,
"waiting_count": waiting,
@@ -382,6 +448,7 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
"smb": smb,
"nfs_count": nfs.len(),
"nfs_active": nfs_active,
"host_bindings": state.hosts.len(),
"uptime_secs": uptime_secs,
"started_at": state.started_at,
"nic_name": state.nic_name,
@@ -650,6 +717,94 @@ async fn api_network_put(
StatusCode::NO_CONTENT
}
// ─── Per-MAC host bindings ────────────────────────────────────────────────
async fn api_hosts_list(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "hosts": state.hosts.list() }))
}
#[derive(Debug, Deserialize)]
struct HostsUpsertBody {
mac: String,
target: String,
#[serde(default)]
label: String,
}
async fn api_hosts_upsert(
State(state): State<AppState>,
Json(body): Json<HostsUpsertBody>,
) -> Response {
let mac = body.mac.trim();
if mac.is_empty() {
return (StatusCode::BAD_REQUEST, "mac is required").into_response();
}
// Sanity-check the target if the operator supplied a real boot
// entry id (anything starting with `_` is a reserved menu shortcut
// and exists by definition).
let target = body.target.trim();
if !target.starts_with('_')
&& !state
.iso_store
.list()
.into_iter()
.any(|i| i.boot_entries.iter().any(|e| e.id == target))
{
return (
StatusCode::BAD_REQUEST,
format!("unknown boot entry: {target}"),
)
.into_response();
}
let binding = state.hosts.upsert(mac, target, body.label.trim());
(StatusCode::CREATED, Json(binding)).into_response()
}
async fn api_hosts_remove(
State(state): State<AppState>,
AxumPath(mac): AxumPath<String>,
) -> StatusCode {
if state.hosts.remove(&mac) {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
// ─── Prometheus metrics ───────────────────────────────────────────────────
async fn api_metrics(State(state): State<AppState>) -> Response {
// Refresh gauges from live state before rendering — keeps the
// scrape "honest" without making /api/status the only path that
// updates them.
state
.metrics
.set_iso_count(state.iso_store.list().len() as u64);
state
.metrics
.set_client_count(state.clients.list().len() as u64);
let gates = state.gates.list();
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
state
.metrics
.set_gate_counts(gates.len() as u64, imaging as u64);
state
.metrics
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
let now = time::OffsetDateTime::now_utc();
let uptime = (now - state.started_at).whole_seconds().max(0) as u64;
let body = state.metrics.render(env!("CARGO_PKG_VERSION"), uptime);
(
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
)],
body,
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
+4 -4
View File
@@ -35,11 +35,11 @@ pub fn render_menu(isos: &[IsoMeta], settings: &Settings, base_url: &str) -> Str
let base = base_url.trim_end_matches('/');
let timeout_ms = settings.boot_menu_timeout_secs.saturating_mul(1000);
let default_item = match settings.timeout_action {
TimeoutAction::LocalHdd => "local",
TimeoutAction::GatedDeployment => "gate",
// Stay -> iPXE's `--timeout 0` is "no timeout". Pick any default
// label; the client waits for keypress.
TimeoutAction::Stay => "local",
// label; the client waits for keypress. We use the same label as
// LocalHdd to keep the menu's pre-highlight stable.
TimeoutAction::LocalHdd | TimeoutAction::Stay => "local",
};
let _ = writeln!(s, "#!ipxe");
@@ -135,7 +135,7 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
/// operator expectations from the original tool).
fn fmt_size_mib(bytes: u64) -> String {
let mib = bytes / (1024 * 1024);
format!("{} MB", mib)
format!("{mib} MB")
}
/// Assign `--key N <id>` hotkeys 1..9, then nothing for positions >=9.
+2 -2
View File
@@ -6,8 +6,8 @@
//! - `/ipxe/<file>` bundled iPXE binaries (for UEFI HTTP boot)
//! - `/iso/<id>.iso` raw ISO file (with Range support)
//! - `/iso/<id>/<path>` files inside the ISO (for wimboot WIM fetches
//! and Linux kernel/initrd, without having to
//! re-extract on every request)
//! and Linux kernel/initrd, without having to
//! re-extract on every request)
//!
//! The `<id>/<path>` handler uses a read-only ISO9660 shim (see `iso_fs`)
//! that lseeks into the ISO on disk — so we never keep extracted copies.
+8 -1
View File
@@ -1,4 +1,4 @@
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore};
use pxeforge_core::{ClientRegistry, GateQueue, HostBindings, LogBus, Metrics, SettingsStore};
use pxeforge_iso_store::{IsoStore, NfsManager, SmbManager};
use std::sync::Arc;
use time::OffsetDateTime;
@@ -9,6 +9,13 @@ pub struct AppState {
pub clients: Arc<ClientRegistry>,
pub settings: Arc<SettingsStore>,
pub gates: Arc<GateQueue>,
/// Per-MAC iPXE script overrides. When a client matching one of
/// these MACs requests `/boot.ipxe`, we chain straight to the
/// configured target instead of rendering the menu.
pub hosts: HostBindings,
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
/// text format. Cheap to clone (handles to atomics).
pub metrics: Metrics,
/// Optional SMB manager. Present when the binary was given a writable
/// `smb_dir` at startup; `None` in pure-Linux-only deployments where
/// Windows support is not wired in. Settings toggle drives start/stop.
+4 -4
View File
@@ -161,8 +161,8 @@ fn clients_text(s: &AppState) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"{:<19} {:<16} {:<8} {}",
"MAC", "IP", "EVENTS", "LAST SEEN"
"{:<19} {:<16} {:<8} LAST SEEN",
"MAC", "IP", "EVENTS"
);
for c in clients {
let ip = c.last_ip.map_or_else(|| "-".into(), |i| i.to_string());
@@ -259,8 +259,8 @@ async fn nfs_command(s: &AppState, args: &[String]) -> Result<String, String> {
let mut out = String::new();
let _ = writeln!(
out,
"{:<24} {:<6} {:<7} {:<6} {}",
"ID", "VER", "STATUS", "ISOS", "TARGET"
"{:<24} {:<6} {:<7} {:<6} TARGET",
"ID", "VER", "STATUS", "ISOS"
);
for m in mounts {
let status = if m.mounted { "ok" } else { "down" };
+207 -3
View File
@@ -12,7 +12,7 @@
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use pxeforge_core::{ClientRegistry, GateQueue, LogBus, SettingsStore};
use pxeforge_core::{ClientRegistry, GateQueue, HostBindings, LogBus, Metrics, SettingsStore};
use pxeforge_http_api::{build_router, AppState};
use pxeforge_iso_store::{IsoStore, NfsManager};
use tempfile::tempdir;
@@ -92,11 +92,15 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
let nfs = NfsManager::new(dir.path(), iso_store.clone());
iso_store.set_nfs_root(nfs.mount_root());
let log_bus = LogBus::new(64);
let hosts = HostBindings::load_or_default(dir.path());
let metrics = Metrics::new();
let state = AppState {
iso_store,
clients,
gates,
settings,
hosts,
metrics,
smb: None,
nfs,
log_bus,
@@ -236,8 +240,8 @@ async fn gated_deployment_full_flow() {
assert!(assign_json.contains(r#""assigned":1"#), "assign response: {assign_json}");
// Now assign to gate 1 too so the background poll wakes.
let body = format!(r#"{{"target":"fake-alpine-linux","gate_ids":[]}}"#);
post_json(&app, "/api/gate/assign", &body).await;
let body = r#"{"target":"fake-alpine-linux","gate_ids":[]}"#;
post_json(&app, "/api/gate/assign", body).await;
let (poll_status, poll_body) = poll_future.await.unwrap();
assert_eq!(poll_status, StatusCode::OK);
@@ -325,6 +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"),
] {
let res = app
.clone()
@@ -438,6 +443,205 @@ async fn log_recent_returns_buffered_lines() {
}
}
#[tokio::test]
async fn windows_iso_renders_clean_wimboot_script_with_no_trust_store_writes() {
// Synthesize an ISO with a Windows volume label + the sources/boot.wim
// sentinel so introspection labels it WindowsPe with has_boot_wim.
let mut buf = vec![0u8; 32 * 2048];
let off = 16 * 2048;
buf[off] = 0x01;
buf[off + 1..off + 6].copy_from_slice(b"CD001");
buf[off + 6] = 0x01;
let label = b"WIN11_X64".to_vec();
let mut padded = label.clone();
padded.resize(32, b' ');
buf[off + 40..off + 40 + 32].copy_from_slice(&padded);
// Sprinkle the sources/boot.wim sentinel where the introspection
// scanner will find it (anywhere in the first 64 MB).
let sentinel = b"SOURCES\\BOOT.WIM";
buf.extend_from_slice(sentinel);
let term = 17 * 2048;
buf[term] = 0xFF;
buf[term + 1..term + 6].copy_from_slice(b"CD001");
buf[term + 6] = 0x01;
let (state, _dir) = build_state().await;
let app = build_router(state);
// Need windows_enabled for the Windows path to render in the menu.
let res = app
.clone()
.oneshot(
Request::builder()
.method("PUT")
.uri("/api/settings")
.header("content-type", "application/json")
.body(Body::from(
r#"{"boot_menu_timeout_secs":600,"timeout_action":"gated_deployment",
"windows_enabled":false,"smb_host_override":"","extra_kernel_args":"",
"default_local_hdd":true,"gate_wait_max_secs":0,"dns_server":""}"#
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
// wimboot binary is bundled in this repo so windows_enabled=true should
// not be rejected; we leave it false to keep the upload path agnostic.
assert_eq!(res.status(), StatusCode::NO_CONTENT);
let (ct, body) = multipart_iso_body("Win11_x64.iso", &buf);
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/isos")
.header("content-type", ct)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
let meta: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(meta["introspection"]["family"], "windows_pe");
assert!(
meta["introspection"]["has_boot_wim"].as_bool().unwrap(),
"introspection should detect sources/boot.wim sentinel"
);
// The boot entry should be a wimboot kind with the canonical 5-file
// chain documented in the LinusTechTips iPXE-Windows guide.
let entry = &meta["boot_entries"][0];
assert_eq!(entry["kind"]["kind"], "wimboot");
let files = entry["kind"]["files"].as_array().unwrap();
let names: Vec<&str> = files.iter().map(|f| f[0].as_str().unwrap()).collect();
assert!(names.contains(&"bootmgr"));
assert!(names.contains(&"bootmgr.efi"));
assert!(names.contains(&"bcd"));
assert!(names.contains(&"boot.sdi"));
assert!(names.contains(&"boot.wim"));
// Render the entry script and verify:
// 1. It uses wimboot
// 2. All 5 files are referenced via `initrd --name`
// 3. NO trust-store / driver / testsigning operations slip in
let entry_id = entry["id"].as_str().unwrap();
let url = format!("/boot/{entry_id}.ipxe");
let (s, body) = get(&app, &url).await;
assert_eq!(s, StatusCode::OK);
let script = String::from_utf8(body).unwrap();
assert!(script.contains("kernel "), "missing kernel line:\n{script}");
assert!(script.contains("ipxe/wimboot"), "missing wimboot loader:\n{script}");
for tag in ["bootmgr", "bootmgr.efi", "bcd", "boot.sdi", "boot.wim"] {
assert!(
script.contains(&format!("initrd --name {tag}")),
"missing `initrd --name {tag}` line:\n{script}"
);
}
// Hard guarantees we never want to see in any client-facing script.
let lower = script.to_lowercase();
for forbidden in [
"bcdedit", "testsigning", "certutil", "test-signed",
"httpdisk", "/set testsigning",
] {
assert!(
!lower.contains(forbidden),
"forbidden trust-store operation `{forbidden}` in script:\n{script}"
);
}
}
#[tokio::test]
async fn host_binding_short_circuits_boot_menu() {
let (state, _dir) = build_state().await;
let app = build_router(state.clone());
// Pin a MAC to the reserved local-hdd boot shortcut. `_local` is a
// built-in target so the upsert validator accepts it without
// requiring a real ISO.
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hosts")
.header("content-type", "application/json")
.body(Body::from(
r#"{"mac":"AA:BB:CC:00:00:01","target":"_local","label":"toms-laptop"}"#
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::CREATED);
// Hit /boot.ipxe with the bound MAC and assert we get the
// short-circuit chain instead of the menu.
let (s1, b1) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:01").await;
assert_eq!(s1, StatusCode::OK);
let body1 = String::from_utf8(b1).unwrap();
assert!(
body1.contains("per-MAC binding"),
"expected MAC short-circuit, got:\n{body1}"
);
assert!(body1.contains("/boot/_local.ipxe"));
// And a different MAC still gets the menu.
let (s2, b2) = get(&app, "/boot.ipxe?mac=ff:ff:ff:ff:ff:ff").await;
assert_eq!(s2, StatusCode::OK);
let body2 = String::from_utf8(b2).unwrap();
assert!(
body2.contains("menu") || body2.contains("Default"),
"expected interactive menu, got:\n{body2}"
);
}
#[tokio::test]
async fn metrics_endpoint_emits_prometheus_format() {
let (state, _dir) = build_state().await;
let app = build_router(state);
// Drive a couple of paths so counters move off zero.
let _ = get(&app, "/api/status").await;
let _ = get(&app, "/boot.ipxe").await;
let res = app
.clone()
.oneshot(Request::builder().uri("/metrics").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let ct = res.headers().get(header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(
ct.starts_with("text/plain"),
"wrong content-type: {ct}"
);
let body = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
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",
] {
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",
] {
let count = body.matches(&format!("# TYPE {name}")).count();
assert_eq!(count, 1, "{name} TYPE line appears {count} times");
}
}
#[tokio::test]
async fn network_endpoint_exposes_dns_round_trip() {
let (state, _dir) = build_state().await;