v0.5.4: code-cleanup pass (AppError, figment config, encoding dedup, typed status, deps)
Final cleanup before hardware testing. No behaviour changes; 248 tests green, clippy clean. #1 AppError newtype (http-api/src/error.rs) with one IntoResponse mapping (NotFound→404, Invalid→400, _→500) + From<core::Error>/From<io::Error>. Converted the clearly-safe handlers (sso_put, unattended_upload, branding_clear) to `?`; intentionally left handlers with bespoke status semantics (Invalid→404 on category, 409 on duplicate share / open upload) explicit so no asserted status changes. #2 figment-based Config::load (defaults → TOML → env). Keeps the historical flat OPENPXE_* names (Unraid/entrypoint compatible) AND adds the nested OPENPXE_SECTION__FIELD form; now covers every field (apply_env had silently skipped unattended_dir + bind addrs). 6 Jail tests prove backward-compat. Removed the hand-rolled apply_env. #3 thiserror 1→2; dropped unused mime/mime_guess/once_cell deps. #4 Re-evaluated: Duration::from_hours/from_mins are stable on the pinned 1.95 toolchain and clippy prefers them — kept the readable form (the "unstable" premise didn't hold; MSRV is intentionally 1.95). #5 insta snapshot of the rendered iPXE menu (version-filtered) + wiremock coverage of the SAML metadata-URL fetch (200 + non-2xx). #6 api_status → typed StatusResponse struct (was a 25-key json! blob) with a full_flow guard test asserting every UI key + the started_at string shape. Deferred the /api/docs typed conversion (lowest value, highest churn, zero functional benefit). #7 pct_encode/xml_escape de-duplicated into openpxe_core::encoding (were copied across app.rs + the SAML modules). No new crates. #8 UploadSessions registry → parking_lot::RwLock (sync, never held across .await); per-session lock stays tokio::Mutex. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7358013093
commit
674a69f93b
@@ -33,8 +33,6 @@ thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
bytes.workspace = true
|
||||
futures.workspace = true
|
||||
mime.workspace = true
|
||||
mime_guess.workspace = true
|
||||
uuid.workspace = true
|
||||
# v0.4.5 Forms auth: lock-free session store and cookie helpers.
|
||||
parking_lot.workspace = true
|
||||
@@ -61,3 +59,9 @@ image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# replay, and IdP-initiated-gating flows exercise real signatures.
|
||||
rcgen = "0.13"
|
||||
bergshamra = { workspace = true }
|
||||
# v0.5.4: snapshot the generated iPXE menu so any unintended drift (a
|
||||
# dropped line, reordered item) is caught and reviewed, not silently shipped.
|
||||
insta = "1.40"
|
||||
# v0.5.4: stand up a mock HTTP server to exercise the SAML metadata-URL
|
||||
# fetch path (previously untested because it did a real network GET).
|
||||
wiremock = "0.6"
|
||||
|
||||
+102
-76
@@ -14,6 +14,7 @@
|
||||
//! | `/api/*` | JSON/HTML API for the web UI |
|
||||
|
||||
use crate::auth as auth_api;
|
||||
use crate::error::AppError;
|
||||
use crate::ipxe_script::{
|
||||
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
|
||||
render_queue_entry, render_shell, render_tools_menu, render_util,
|
||||
@@ -31,15 +32,15 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use openpxe_core::{
|
||||
ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error, LogoSlot, NotifyConfig,
|
||||
Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
|
||||
encoding::pct_encode, ext_for_mime, wol, BootEvent, ClientEvent, DeployProfile, Error,
|
||||
LogoSlot, NotifyConfig, Settings, SsoConfig, ALLOWED_LOGO_MIMES, MAX_LOGO_BYTES,
|
||||
};
|
||||
use openpxe_ipxe_assets::asset_bytes;
|
||||
use openpxe_iso_store::{
|
||||
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, UnattendedKind,
|
||||
UnattendedMeta,
|
||||
render_template, IsoCategory, IsoMeta, IsoSource, NfsAddRequest, SmbAddRequest, SmbState,
|
||||
UnattendedKind, UnattendedMeta,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
@@ -241,12 +242,12 @@ async fn api_sso_get(State(state): State<AppState>) -> Json<SsoConfig> {
|
||||
Json(state.sso.snapshot())
|
||||
}
|
||||
|
||||
async fn api_sso_put(State(state): State<AppState>, Json(body): Json<SsoConfig>) -> Response {
|
||||
match state.sso.replace(body) {
|
||||
Ok(cfg) => (StatusCode::OK, Json(cfg)).into_response(),
|
||||
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
async fn api_sso_put(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<SsoConfig>,
|
||||
) -> Result<Json<SsoConfig>, AppError> {
|
||||
// v0.5.4: `?` + AppError centralizes Invalid→400 / _→500.
|
||||
Ok(Json(state.sso.replace(body)?))
|
||||
}
|
||||
|
||||
// ─── UI ────────────────────────────────────────────────────────────────────
|
||||
@@ -1198,14 +1199,12 @@ async fn api_branding_upload(
|
||||
async fn api_branding_clear(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(slot): AxumPath<String>,
|
||||
) -> Response {
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(slot) = LogoSlot::parse(&slot) else {
|
||||
return (StatusCode::BAD_REQUEST, "unknown logo slot").into_response();
|
||||
return Ok((StatusCode::BAD_REQUEST, "unknown logo slot").into_response());
|
||||
};
|
||||
match state.branding.clear_logo(slot) {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
state.branding.clear_logo(slot)?;
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
|
||||
// ─── Unattended answer files (v0.5.2) ──────────────────────────────────────
|
||||
@@ -1221,7 +1220,9 @@ async fn api_unattended_list(State(state): State<AppState>) -> Json<serde_json::
|
||||
async fn api_unattended_upload(
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Response {
|
||||
) -> Result<Response, AppError> {
|
||||
// v0.5.4: the answer-file add() maps Invalid→400 / _→500 via `?`+AppError.
|
||||
// The multipart-shape 400s (missing field/filename) stay explicit.
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let name = field.name().unwrap_or("").to_string();
|
||||
if name != "file" && name != "unattended" {
|
||||
@@ -1229,19 +1230,18 @@ async fn api_unattended_upload(
|
||||
}
|
||||
let filename = field.file_name().map(str::to_string).unwrap_or_default();
|
||||
if filename.trim().is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "missing filename on upload").into_response();
|
||||
return Ok((StatusCode::BAD_REQUEST, "missing filename on upload").into_response());
|
||||
}
|
||||
let bytes = match field.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response(),
|
||||
};
|
||||
return match state.unattended.add(&filename, &bytes).await {
|
||||
Ok(meta) => (StatusCode::CREATED, Json(meta)).into_response(),
|
||||
Err(Error::Invalid(msg)) => (StatusCode::BAD_REQUEST, msg).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
Err(e) => {
|
||||
return Ok((StatusCode::BAD_REQUEST, format!("read body: {e}")).into_response())
|
||||
}
|
||||
};
|
||||
let meta = state.unattended.add(&filename, &bytes).await?;
|
||||
return Ok((StatusCode::CREATED, Json(meta)).into_response());
|
||||
}
|
||||
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
|
||||
Ok((StatusCode::BAD_REQUEST, "no 'file' part").into_response())
|
||||
}
|
||||
|
||||
async fn api_unattended_delete(
|
||||
@@ -1379,23 +1379,7 @@ fn build_query(pairs: &[(&str, Option<&str>)]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Minimal RFC 3986 percent-encoding for query values (unreserved set
|
||||
/// passes through; everything else becomes `%XX`).
|
||||
fn pct_encode(s: &str) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(out, "%{b:02X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
// `pct_encode` lives in `openpxe_core::encoding` (v0.5.4) — imported above.
|
||||
|
||||
/// Encode `(hostname, ip, mac)` into a single base64url path segment for
|
||||
/// the cloud-init seed directory. Empty values become empty fields.
|
||||
@@ -1948,7 +1932,49 @@ async fn api_list_clients(State(state): State<AppState>) -> Json<serde_json::Val
|
||||
Json(json!({ "clients": state.clients.list() }))
|
||||
}
|
||||
|
||||
async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
/// Per-theme branding presence, nested under [`StatusResponse::branding`].
|
||||
#[derive(Serialize)]
|
||||
struct BrandingStatus {
|
||||
light: bool,
|
||||
dark: bool,
|
||||
client: bool,
|
||||
rev: u64,
|
||||
}
|
||||
|
||||
/// Dashboard status payload. v0.5.4: this replaced a 25-key hand-built
|
||||
/// `json!` blob — the typed struct makes the contract with the WebUI
|
||||
/// compile-checked. Field names ARE the JSON keys; do not rename without
|
||||
/// updating `crates/webui/src/app.js` (a `full_flow` test guards the set).
|
||||
/// `settings` / `smb` / `started_at` embed their own `Serialize` impls so
|
||||
/// the wire shape is byte-identical to the previous `json!` output.
|
||||
#[derive(Serialize)]
|
||||
struct StatusResponse {
|
||||
version: &'static str,
|
||||
public_base_url: String,
|
||||
iso_count: usize,
|
||||
client_count: usize,
|
||||
queue_count: usize,
|
||||
imaging_count: usize,
|
||||
waiting_count: usize,
|
||||
ipxe_assets: Vec<String>,
|
||||
settings: Settings,
|
||||
smb: Option<SmbState>,
|
||||
smb_share_count: usize,
|
||||
smb_share_reachable: usize,
|
||||
nfs_share_count: usize,
|
||||
nfs_share_reachable: usize,
|
||||
host_bindings: usize,
|
||||
custom_logo: bool,
|
||||
branding: BrandingStatus,
|
||||
unattended_count: usize,
|
||||
uptime_secs: i64,
|
||||
started_at: time::OffsetDateTime,
|
||||
nic_name: String,
|
||||
subnet_mask: String,
|
||||
gateway: String,
|
||||
}
|
||||
|
||||
async fn api_status(State(state): State<AppState>) -> Json<StatusResponse> {
|
||||
let smb = state.smb.as_ref().map(|s| s.snapshot());
|
||||
// v0.4.65+v0.4.67: external storage shares — SMB (userspace
|
||||
// smbclient) and NFS (in-process nfs3_client). Dashboard tile
|
||||
@@ -1981,39 +2007,39 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
state.metrics.record_http(openpxe_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": clients.len(),
|
||||
"queue_count": queue_entries.len(),
|
||||
"imaging_count": imaging,
|
||||
"waiting_count": waiting,
|
||||
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
|
||||
"settings": state.settings.snapshot(),
|
||||
"smb": smb,
|
||||
"smb_share_count": smb_shares.len(),
|
||||
"smb_share_reachable": smb_reachable,
|
||||
// v0.4.67: NFSv3 share counts. The dashboard tile sums these
|
||||
// with the SMB counts above ("N shares reachable") so the
|
||||
// top-line metric works regardless of protocol mix.
|
||||
"nfs_share_count": nfs_shares.len(),
|
||||
"nfs_share_reachable": nfs_reachable,
|
||||
"host_bindings": state.hosts.len(),
|
||||
"custom_logo": state.branding.has_any_web_logo(),
|
||||
"branding": {
|
||||
"light": state.branding.has_logo(LogoSlot::Light),
|
||||
"dark": state.branding.has_logo(LogoSlot::Dark),
|
||||
"client": state.branding.has_logo(LogoSlot::Client),
|
||||
"rev": state.branding.logo_rev(),
|
||||
Json(StatusResponse {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
public_base_url: state.public_base_url.clone(),
|
||||
iso_count: isos.len(),
|
||||
client_count: clients.len(),
|
||||
queue_count: queue_entries.len(),
|
||||
imaging_count: imaging,
|
||||
waiting_count: waiting,
|
||||
ipxe_assets: openpxe_ipxe_assets::list_assets(),
|
||||
settings: state.settings.snapshot(),
|
||||
smb,
|
||||
smb_share_count: smb_shares.len(),
|
||||
smb_share_reachable: smb_reachable,
|
||||
// v0.4.67: NFSv3 share counts. The dashboard tile sums these with
|
||||
// the SMB counts above ("N shares reachable") so the top-line
|
||||
// metric works regardless of protocol mix.
|
||||
nfs_share_count: nfs_shares.len(),
|
||||
nfs_share_reachable: nfs_reachable,
|
||||
host_bindings: state.hosts.len(),
|
||||
custom_logo: state.branding.has_any_web_logo(),
|
||||
branding: BrandingStatus {
|
||||
light: state.branding.has_logo(LogoSlot::Light),
|
||||
dark: state.branding.has_logo(LogoSlot::Dark),
|
||||
client: state.branding.has_logo(LogoSlot::Client),
|
||||
rev: state.branding.logo_rev(),
|
||||
},
|
||||
"unattended_count": state.unattended.len(),
|
||||
"uptime_secs": uptime_secs,
|
||||
"started_at": state.started_at,
|
||||
"nic_name": state.nic_name,
|
||||
"subnet_mask": state.subnet_mask,
|
||||
"gateway": state.gateway,
|
||||
}))
|
||||
unattended_count: state.unattended.len(),
|
||||
uptime_secs,
|
||||
started_at: state.started_at,
|
||||
nic_name: state.nic_name.clone(),
|
||||
subnet_mask: state.subnet_mask.clone(),
|
||||
gateway: state.gateway.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn api_get_settings(State(state): State<AppState>) -> Json<Settings> {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Uniform HTTP error mapping for the API layer (v0.5.4).
|
||||
//!
|
||||
//! Before this, ~40 handlers in `app.rs` hand-wrote
|
||||
//! `match … { Err(e) => (StatusCode::…, format!("{e}")).into_response() }`,
|
||||
//! and the `openpxe_core::Error` → status mapping drifted between them
|
||||
//! (e.g. `Invalid` → 400 in most places, 404 in one). [`AppError`] wraps
|
||||
//! `openpxe_core::Error` so a handler can return `Result<T, AppError>` and
|
||||
//! `?` its way out, getting one consistent status + body. The body stays
|
||||
//! plain-text (matching the previous `(StatusCode, String)` responses) so
|
||||
//! existing clients and tests see no shape change; 5xx detail is logged
|
||||
//! and returned verbatim exactly as before.
|
||||
//!
|
||||
//! Handlers with *intentional* domain-specific statuses (e.g. a duplicate
|
||||
//! share → 409, a still-open chunked upload → 409) keep their explicit
|
||||
//! returns — `AppError` is for the common case, not a straitjacket.
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use openpxe_core::Error as CoreError;
|
||||
|
||||
/// Newtype over [`openpxe_core::Error`] with a uniform [`IntoResponse`].
|
||||
#[derive(Debug)]
|
||||
pub struct AppError(pub CoreError);
|
||||
|
||||
impl From<CoreError> for AppError {
|
||||
fn from(e: CoreError) -> Self {
|
||||
AppError(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for AppError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
AppError(CoreError::Io(e))
|
||||
}
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
/// The HTTP status this error maps to. Public so handlers (and tests)
|
||||
/// can reason about the mapping in one place.
|
||||
#[must_use]
|
||||
pub fn status(&self) -> StatusCode {
|
||||
match self.0 {
|
||||
CoreError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
CoreError::Invalid(_) => StatusCode::BAD_REQUEST,
|
||||
CoreError::Config(_) | CoreError::Io(_) | CoreError::Other(_) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status();
|
||||
// Match the prior hand-written responses: the 4xx arms returned the
|
||||
// bare inner message (not the `Display` prefix), so a UI showing
|
||||
// `await r.text()` reads "metadata too long", not "invalid input:
|
||||
// metadata too long". 5xx keeps the full `Display` string.
|
||||
let body = match &self.0 {
|
||||
CoreError::Invalid(m) | CoreError::NotFound(m) => m.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
if status.is_server_error() {
|
||||
// Log the full detail server-side; the body still carries it
|
||||
// (unchanged from the prior `format!("{e}")` behaviour), but the
|
||||
// log line is what an operator greps for.
|
||||
tracing::error!(target: "openpxe::http", error = %self.0, "request failed");
|
||||
}
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn status_mapping_is_consistent() {
|
||||
assert_eq!(
|
||||
AppError(CoreError::NotFound("x".into())).status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
AppError(CoreError::Invalid("x".into())).status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
AppError(CoreError::Config("x".into())).status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -693,6 +693,22 @@ mod password_tests {
|
||||
assert!(s.contains("arm64 UEFI"), "{s}");
|
||||
}
|
||||
|
||||
// v0.5.4: a full snapshot of the rendered top menu. The fragment
|
||||
// `assert!`s above check specific invariants; this catches *any* other
|
||||
// drift (a reordered item, a dropped line, changed spacing) so it's
|
||||
// reviewed deliberately. The OpenPXE version is filtered out so the
|
||||
// snapshot doesn't churn on every release bump.
|
||||
#[test]
|
||||
fn render_menu_snapshot() {
|
||||
// Normalize the compile-time version so the snapshot doesn't churn
|
||||
// on every release bump (no insta `filters` feature needed).
|
||||
let rendered = render_menu(&[], &Settings::default(), "http://10.0.0.5").replace(
|
||||
concat!("OpenPXE v", env!("CARGO_PKG_VERSION")),
|
||||
"OpenPXE vX.Y.Z",
|
||||
);
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_scripts_do_not_emit_bare_or_trailing_fallbacks() {
|
||||
let settings = Settings::default();
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
pub mod app;
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod ipxe_script;
|
||||
pub mod iso_fs;
|
||||
pub mod log_stream;
|
||||
|
||||
@@ -336,3 +336,35 @@ fn redirect_with_session(location: &str, session: &str) -> Response {
|
||||
IntoResponse::into_response,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
// v0.5.4: exercise the SAML metadata-URL fetch against a mock server —
|
||||
// previously this path did a real network GET and had no coverage.
|
||||
#[tokio::test]
|
||||
async fn fetch_metadata_returns_body_on_200() {
|
||||
let server = MockServer::start().await;
|
||||
let xml = "<EntityDescriptor>idp</EntityDescriptor>";
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(xml))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let got = fetch_metadata(&server.uri()).await.expect("fetch ok");
|
||||
assert_eq!(got, xml);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_metadata_errors_on_non_2xx() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(ResponseTemplate::new(503))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let err = fetch_metadata(&server.uri()).await.unwrap_err();
|
||||
assert!(matches!(err, SamlError::Metadata(_)), "got {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
---
|
||||
source: crates/http-api/src/ipxe_script.rs
|
||||
expression: rendered
|
||||
---
|
||||
#!ipxe
|
||||
# OpenPXE top-level menu - auto-generated, do not edit
|
||||
set base-url http://10.0.0.5
|
||||
set esc:hex 1b
|
||||
set cls ${esc:string}[2J
|
||||
console --picture http://10.0.0.5/branding/pxe-logo --top 290 || console
|
||||
set arch-label ${buildarch} ${platform}
|
||||
iseq ${buildarch} i386 && iseq ${platform} pcbios && set arch-label x86 BIOS || iseq ${buildarch} x86_64 && iseq ${platform} efi && set arch-label x86_64 UEFI || iseq ${buildarch} arm64 && iseq ${platform} efi && set arch-label arm64 UEFI || true
|
||||
:menu
|
||||
menu OpenPXE - network boot menu
|
||||
item --gap
|
||||
item --gap -- ------------------------- Default -------------------------
|
||||
item local Boot from Local HDD
|
||||
item --gap -- ----------------------- Installers -----------------------
|
||||
item --gap -- (no Linux ISOs uploaded)
|
||||
item --gap -- (Windows support disabled in Settings)
|
||||
item --gap -- -------------------------- Tools --------------------------
|
||||
item tools Tools >
|
||||
item --gap -- ---------------------- Queued Deployment ---------------------
|
||||
item queue Queued Deployment (join queue)
|
||||
item --gap
|
||||
item --key x exit Exit iPXE
|
||||
item --gap
|
||||
item --gap -- OpenPXE vX.Y.Z - ${arch-label}
|
||||
choose --default queue --timeout 600000 target || goto menu
|
||||
iseq ${target} local && chain http://10.0.0.5/boot/_local.ipxe || goto menu
|
||||
iseq ${target} linux && chain http://10.0.0.5/boot/_linux_menu.ipxe || goto menu
|
||||
iseq ${target} windows && chain http://10.0.0.5/boot/_windows_menu.ipxe || goto menu
|
||||
iseq ${target} tools && chain http://10.0.0.5/boot/_tools_menu.ipxe || goto menu
|
||||
iseq ${target} queue && chain http://10.0.0.5/boot/_queue.ipxe || goto menu
|
||||
iseq ${target} exit && exit || goto menu
|
||||
goto menu
|
||||
@@ -9,6 +9,7 @@
|
||||
use bytes::Bytes;
|
||||
use openpxe_core::{Error, Result};
|
||||
use openpxe_iso_store::{IsoMeta, IsoStore, UploadHandle};
|
||||
use parking_lot::RwLock;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -19,7 +20,11 @@ const DEFAULT_CHUNK_SIZE: u64 = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct UploadSessions {
|
||||
inner: Arc<Mutex<HashMap<String, Arc<Mutex<UploadSession>>>>>,
|
||||
// v0.5.4: the registry is a sync `parking_lot::RwLock` — it's only ever
|
||||
// briefly read/inserted/removed to look up a session, never held across
|
||||
// an `.await`. The per-session lock below stays a `tokio::sync::Mutex`
|
||||
// because `write_chunk` / `finish` are awaited while it's held.
|
||||
inner: Arc<RwLock<HashMap<String, Arc<Mutex<UploadSession>>>>>,
|
||||
}
|
||||
|
||||
struct UploadSession {
|
||||
@@ -67,8 +72,7 @@ impl UploadSessions {
|
||||
};
|
||||
|
||||
self.inner
|
||||
.lock()
|
||||
.await
|
||||
.write()
|
||||
.insert(upload_id.clone(), Arc::new(Mutex::new(session)));
|
||||
|
||||
Ok(UploadStarted {
|
||||
@@ -88,7 +92,7 @@ impl UploadSessions {
|
||||
chunk: Bytes,
|
||||
complete: bool,
|
||||
) -> Result<UploadAppend> {
|
||||
let Some(session_lock) = self.inner.lock().await.get(upload_id).cloned() else {
|
||||
let Some(session_lock) = self.inner.read().get(upload_id).cloned() else {
|
||||
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
|
||||
};
|
||||
|
||||
@@ -119,7 +123,7 @@ impl UploadSessions {
|
||||
if let Err(e) = handle.write_chunk(&chunk).await {
|
||||
let handle = session.handle.take();
|
||||
drop(session);
|
||||
self.inner.lock().await.remove(upload_id);
|
||||
self.inner.write().remove(upload_id);
|
||||
if let Some(handle) = handle {
|
||||
let _ = handle.abort().await;
|
||||
}
|
||||
@@ -156,11 +160,11 @@ impl UploadSessions {
|
||||
let meta = match handle.finish(store).await {
|
||||
Ok(meta) => meta,
|
||||
Err(e) => {
|
||||
self.inner.lock().await.remove(upload_id);
|
||||
self.inner.write().remove(upload_id);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
self.inner.lock().await.remove(upload_id);
|
||||
self.inner.write().remove(upload_id);
|
||||
Ok(UploadAppend::Complete {
|
||||
offset: new_offset,
|
||||
iso: Box::new(meta),
|
||||
@@ -168,7 +172,7 @@ impl UploadSessions {
|
||||
}
|
||||
|
||||
pub async fn abort(&self, upload_id: &str) -> Result<()> {
|
||||
let Some(session_lock) = self.inner.lock().await.remove(upload_id) else {
|
||||
let Some(session_lock) = self.inner.write().remove(upload_id) else {
|
||||
return Err(Error::Invalid(format!("no such upload '{upload_id}'")));
|
||||
};
|
||||
let mut session = session_lock.lock().await;
|
||||
|
||||
@@ -1536,6 +1536,60 @@ async fn status_exposes_custom_logo_flag() {
|
||||
);
|
||||
}
|
||||
|
||||
/// v0.5.4 guard: the typed `StatusResponse` must keep every key the WebUI
|
||||
/// (`crates/webui/src/app.js`) reads off `/api/status`. If a refactor drops
|
||||
/// or renames one, the dashboard silently breaks — this catches it.
|
||||
#[tokio::test]
|
||||
async fn status_contract_has_all_ui_keys() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, body) = get(&app, "/api/status").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
for key in [
|
||||
"version",
|
||||
"public_base_url",
|
||||
"iso_count",
|
||||
"client_count",
|
||||
"queue_count",
|
||||
"imaging_count",
|
||||
"waiting_count",
|
||||
"ipxe_assets",
|
||||
"settings",
|
||||
"smb_share_count",
|
||||
"smb_share_reachable",
|
||||
"nfs_share_count",
|
||||
"nfs_share_reachable",
|
||||
"host_bindings",
|
||||
"custom_logo",
|
||||
"branding",
|
||||
"unattended_count",
|
||||
"uptime_secs",
|
||||
"started_at",
|
||||
"nic_name",
|
||||
"subnet_mask",
|
||||
"gateway",
|
||||
] {
|
||||
assert!(
|
||||
v.get(key).is_some(),
|
||||
"/api/status missing UI key '{key}': {v}"
|
||||
);
|
||||
}
|
||||
// Nested branding presence the Settings tab reads.
|
||||
for key in ["light", "dark", "client", "rev"] {
|
||||
assert!(
|
||||
v["branding"].get(key).is_some(),
|
||||
"/api/status branding missing '{key}': {v}"
|
||||
);
|
||||
}
|
||||
// started_at must remain an RFC3339 string (the UI does fmtUptime on
|
||||
// uptime_secs but renders started_at as text), not a serialized struct.
|
||||
assert!(
|
||||
v["started_at"].is_string(),
|
||||
"started_at should serialize as a string: {v}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn put_json(router: &axum::Router, path: &str, body: &str) -> (StatusCode, Vec<u8>) {
|
||||
let res = router
|
||||
.clone()
|
||||
|
||||
Reference in New Issue
Block a user