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
+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> {
|
||||
|
||||
Reference in New Issue
Block a user