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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user