Name update
This commit is contained in:
+245
-127
@@ -14,8 +14,8 @@
|
||||
//! | `/api/*` | JSON/HTML API for the web UI |
|
||||
|
||||
use crate::ipxe_script::{
|
||||
render_entry, render_family_menu, render_queue_entry, render_local_hdd,
|
||||
render_menu, render_nic_info, render_shell, render_tools_menu, render_util,
|
||||
render_entry, render_family_menu, render_local_hdd, render_menu, render_nic_info,
|
||||
render_queue_entry, render_shell, render_tools_menu, render_util,
|
||||
};
|
||||
use crate::iso_fs;
|
||||
use crate::log_stream;
|
||||
@@ -61,7 +61,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// JSON API.
|
||||
.route("/api/isos", get(api_list_isos).post(api_upload_iso))
|
||||
.route("/api/isos/:id", delete(api_delete_iso))
|
||||
// v0.3.1: per-ISO password gate. PUT body `{ "password": "..." }`
|
||||
// Per-ISO password prompt. PUT body `{ "password": "..." }`
|
||||
// sets, `{ "password": null }` (or DELETE) clears.
|
||||
.route(
|
||||
"/api/isos/:id/password",
|
||||
@@ -105,42 +105,67 @@ pub fn build_router(state: AppState) -> Router {
|
||||
|
||||
async fn index(State(state): State<AppState>) -> Response {
|
||||
let html = openpxe_webui::index_html(&state.public_base_url);
|
||||
([(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"))], html)
|
||||
(
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
)],
|
||||
html,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn ui_js() -> Response {
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("application/javascript"))],
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/javascript"),
|
||||
)],
|
||||
openpxe_webui::app_js(),
|
||||
).into_response()
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn ui_css() -> Response {
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
|
||||
openpxe_webui::app_css(),
|
||||
).into_response()
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn ui_logo() -> Response {
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("image/svg+xml"),
|
||||
)],
|
||||
openpxe_webui::logo_svg(),
|
||||
).into_response()
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn ui_loader() -> Response {
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml"))],
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("image/svg+xml"),
|
||||
)],
|
||||
openpxe_webui::loader_svg(),
|
||||
).into_response()
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ─── iPXE scripts ──────────────────────────────────────────────────────────
|
||||
|
||||
fn text_plain(body: String) -> Response {
|
||||
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"))], body)
|
||||
(
|
||||
[(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||
)],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -148,11 +173,10 @@ fn text_plain(body: String) -> Response {
|
||||
/// 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(openpxe_core::HttpRoute::BootScript);
|
||||
async fn boot_top_menu(State(state): State<AppState>, Query(p): Query<BootMenuParams>) -> Response {
|
||||
state
|
||||
.metrics
|
||||
.record_http(openpxe_core::HttpRoute::BootScript);
|
||||
let isos = state.iso_store.list();
|
||||
let settings = state.settings.snapshot();
|
||||
let base = &state.public_base_url;
|
||||
@@ -212,19 +236,19 @@ async fn boot_sub(
|
||||
let settings = state.settings.snapshot();
|
||||
let base = &state.public_base_url;
|
||||
let script = match name {
|
||||
"_local" => render_local_hdd(base),
|
||||
"_linux_menu" => render_family_menu(&isos, base, false),
|
||||
"_windows_menu" => render_family_menu(&isos, base, true),
|
||||
"_tools_menu" => render_tools_menu(base),
|
||||
"_util" => render_util(base),
|
||||
"_shell" => render_shell(base),
|
||||
"_nic" => render_nic_info(base),
|
||||
"_queue" => render_queue_entry(base),
|
||||
"_local" => render_local_hdd(base),
|
||||
"_linux_menu" => render_family_menu(&isos, base, false),
|
||||
"_windows_menu" => render_family_menu(&isos, base, true),
|
||||
"_tools_menu" => render_tools_menu(base),
|
||||
"_util" => render_util(base),
|
||||
"_shell" => render_shell(base),
|
||||
"_nic" => render_nic_info(base),
|
||||
"_queue" => render_queue_entry(base),
|
||||
other => {
|
||||
for iso in &isos {
|
||||
for entry in &iso.boot_entries {
|
||||
if entry.id == other {
|
||||
// Password gate. If the ISO has a password set
|
||||
// Password prompt. If the ISO has a password set
|
||||
// we block the actual boot script behind it:
|
||||
// - no token -> render a prompt
|
||||
// - wrong token -> render auth-fail
|
||||
@@ -235,38 +259,45 @@ async fn boot_sub(
|
||||
match p.token.as_deref() {
|
||||
None | Some("") => {
|
||||
return text_plain(crate::ipxe_script::render_password_prompt(
|
||||
&entry.id, &iso.filename, base,
|
||||
&entry.id,
|
||||
&iso.filename,
|
||||
base,
|
||||
));
|
||||
}
|
||||
Some(token) => match state.iso_store.verify_password(&iso.id, token) {
|
||||
Ok(true) => { /* fall through to render the entry */ }
|
||||
Ok(false) => {
|
||||
// Don't log the candidate — just the
|
||||
// mac (when iPXE supplies one) and
|
||||
// the entry id, so an operator can
|
||||
// see brute-force attempts in the
|
||||
// live log.
|
||||
tracing::warn!(
|
||||
target: "openpxe::http::boot",
|
||||
entry = %other,
|
||||
"wrong password supplied for protected boot entry"
|
||||
);
|
||||
return text_plain(
|
||||
crate::ipxe_script::render_password_failed(
|
||||
&entry.id, base,
|
||||
),
|
||||
);
|
||||
Some(token) => {
|
||||
match state.iso_store.verify_password(&iso.id, token) {
|
||||
Ok(true) => { /* fall through to render the entry */ }
|
||||
Ok(false) => {
|
||||
// Don't log the candidate — just the
|
||||
// mac (when iPXE supplies one) and
|
||||
// the entry id, so an operator can
|
||||
// see brute-force attempts in the
|
||||
// live log.
|
||||
tracing::warn!(
|
||||
target: "openpxe::http::boot",
|
||||
entry = %other,
|
||||
"wrong password supplied for protected boot entry"
|
||||
);
|
||||
return text_plain(
|
||||
crate::ipxe_script::render_password_failed(
|
||||
&entry.id, base,
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
target: "openpxe::http::boot",
|
||||
entry = %other, error = %e,
|
||||
"password verify failed unexpectedly"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"password check failed",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
target: "openpxe::http::boot",
|
||||
entry = %other, error = %e,
|
||||
"password verify failed unexpectedly"
|
||||
);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"password check failed").into_response();
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return text_plain(render_entry(entry, &settings, base));
|
||||
@@ -290,11 +321,15 @@ async fn ipxe_binary(AxumPath(name): AxumPath<String>) -> Response {
|
||||
};
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, HeaderValue::from_static("application/octet-stream")),
|
||||
(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
),
|
||||
(header::CONTENT_LENGTH, HeaderValue::from(bytes.len())),
|
||||
],
|
||||
bytes,
|
||||
).into_response()
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ─── ISO streaming (raw + in-ISO) ─────────────────────────────────────────
|
||||
@@ -324,7 +359,9 @@ async fn iso_file(
|
||||
let p = iso_path.clone();
|
||||
let in_path = format!("/{path}");
|
||||
let loc = tokio::task::spawn_blocking(move || iso_fs::lookup(&p, &in_path))
|
||||
.await.ok().flatten();
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(loc) = loc else {
|
||||
return (StatusCode::NOT_FOUND, "not found inside iso").into_response();
|
||||
};
|
||||
@@ -340,21 +377,43 @@ async fn stream_file_range(
|
||||
) -> anyhow::Result<Response> {
|
||||
let meta = tokio::fs::metadata(path).await?;
|
||||
let total = meta.len();
|
||||
let (start, end, partial) = parse_range(range, total);
|
||||
if total == 0 {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CONTENT_LENGTH, 0)
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
}
|
||||
let Some((start, end, partial)) = parse_range(range, total) else {
|
||||
return Ok(Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
|
||||
.body(Body::empty())
|
||||
.unwrap());
|
||||
};
|
||||
let len = end - start + 1;
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
file.seek(std::io::SeekFrom::Start(start)).await?;
|
||||
let reader = file.take(len);
|
||||
let stream = tokio_util::io::ReaderStream::new(reader);
|
||||
let body = Body::from_stream(stream);
|
||||
let status = if partial { StatusCode::PARTIAL_CONTENT } else { StatusCode::OK };
|
||||
let status = if partial {
|
||||
StatusCode::PARTIAL_CONTENT
|
||||
} else {
|
||||
StatusCode::OK
|
||||
};
|
||||
let mut builder = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CONTENT_LENGTH, len);
|
||||
if partial {
|
||||
builder = builder.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"));
|
||||
builder = builder.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {start}-{end}/{total}"),
|
||||
);
|
||||
}
|
||||
Ok(builder.body(body).unwrap())
|
||||
}
|
||||
@@ -377,21 +436,40 @@ async fn stream_byte_range(
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
fn parse_range(h: Option<&HeaderValue>, total: u64) -> (u64, u64, bool) {
|
||||
let Some(h) = h else { return (0, total.saturating_sub(1), false); };
|
||||
let Ok(s) = h.to_str() else { return (0, total.saturating_sub(1), false); };
|
||||
let Some(spec) = s.strip_prefix("bytes=") else { return (0, total.saturating_sub(1), false); };
|
||||
fn parse_range(h: Option<&HeaderValue>, total: u64) -> Option<(u64, u64, bool)> {
|
||||
let Some(h) = h else {
|
||||
return Some((0, total.saturating_sub(1), false));
|
||||
};
|
||||
let Ok(s) = h.to_str() else {
|
||||
return Some((0, total.saturating_sub(1), false));
|
||||
};
|
||||
let Some(spec) = s.strip_prefix("bytes=") else {
|
||||
return Some((0, total.saturating_sub(1), false));
|
||||
};
|
||||
let spec = spec.split(',').next().unwrap_or("").trim();
|
||||
if let Some(suffix) = spec.strip_prefix('-') {
|
||||
if let Ok(n) = suffix.parse::<u64>() {
|
||||
let n = n.min(total);
|
||||
return (total.saturating_sub(n), total.saturating_sub(1), true);
|
||||
return Some((total.saturating_sub(n), total.saturating_sub(1), true));
|
||||
}
|
||||
}
|
||||
let mut parts = spec.splitn(2, '-');
|
||||
let start = parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||
let end = parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(total.saturating_sub(1));
|
||||
(start, end.min(total.saturating_sub(1)), true)
|
||||
let start = parts
|
||||
.next()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
let end = parts
|
||||
.next()
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(total.saturating_sub(1));
|
||||
if start >= total {
|
||||
return None;
|
||||
}
|
||||
let end = end.min(total.saturating_sub(1));
|
||||
if start > end {
|
||||
return None;
|
||||
}
|
||||
Some((start, end, true))
|
||||
}
|
||||
|
||||
// ─── ISO upload / list / delete ───────────────────────────────────────────
|
||||
@@ -442,8 +520,10 @@ async fn api_set_iso_password(
|
||||
);
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Err(pxeforge_error_invalid) if matches!(pxeforge_error_invalid, openpxe_core::Error::Invalid(_)) => {
|
||||
(StatusCode::NOT_FOUND, format!("{pxeforge_error_invalid}")).into_response()
|
||||
Err(openpxe_error_invalid)
|
||||
if matches!(openpxe_error_invalid, openpxe_core::Error::Invalid(_)) =>
|
||||
{
|
||||
(StatusCode::NOT_FOUND, format!("{openpxe_error_invalid}")).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
@@ -465,12 +545,11 @@ async fn api_clear_iso_password(
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_upload_iso(
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Response {
|
||||
async fn api_upload_iso(State(state): State<AppState>, mut multipart: Multipart) -> Response {
|
||||
while let Ok(Some(mut field)) = multipart.next_field().await {
|
||||
if field.name() != Some("file") { continue; }
|
||||
if field.name() != Some("file") {
|
||||
continue;
|
||||
}
|
||||
let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
|
||||
if !filename.to_ascii_lowercase().ends_with(".iso") {
|
||||
return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response();
|
||||
@@ -499,7 +578,11 @@ async fn api_upload_iso(
|
||||
async fn healthz() -> Response {
|
||||
// Simple liveness — HTTP task is responsive. Does not touch storage or
|
||||
// other subsystems so we never fail for downstream reasons.
|
||||
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ok\n").into_response()
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
|
||||
"ok\n",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn readyz(State(state): State<AppState>) -> Response {
|
||||
@@ -517,7 +600,11 @@ async fn readyz(State(state): State<AppState>) -> Response {
|
||||
problems.push("iso directory not readable");
|
||||
}
|
||||
if problems.is_empty() {
|
||||
([(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], "ready\n").into_response()
|
||||
(
|
||||
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
|
||||
"ready\n",
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
let body = format!("not ready:\n- {}\n", problems.join("\n- "));
|
||||
(StatusCode::SERVICE_UNAVAILABLE, body).into_response()
|
||||
@@ -536,17 +623,22 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
let nfs_active = nfs.iter().filter(|m| m.mounted).count();
|
||||
let isos = state.iso_store.list();
|
||||
let clients = state.clients.list();
|
||||
let gates = state.queue.list();
|
||||
// Phase 4: dashboard tracks "imaging" as gates with an assignment
|
||||
let queue_entries = state.queue.list();
|
||||
// Phase 4: dashboard tracks "imaging" as queue entries 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;
|
||||
let imaging = queue_entries
|
||||
.iter()
|
||||
.filter(|entry| entry.assigned_target.is_some())
|
||||
.count();
|
||||
let waiting = queue_entries.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_queue_counts(gates.len() as u64, imaging as u64);
|
||||
state
|
||||
.metrics
|
||||
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
||||
state.metrics.set_nfs_active(nfs_active as u64);
|
||||
state.metrics.record_http(openpxe_core::HttpRoute::Api);
|
||||
let now = time::OffsetDateTime::now_utc();
|
||||
@@ -556,7 +648,7 @@ async fn api_status(State(state): State<AppState>) -> Json<serde_json::Value> {
|
||||
"public_base_url": state.public_base_url,
|
||||
"iso_count": isos.len(),
|
||||
"client_count": clients.len(),
|
||||
"queue_count": gates.len(),
|
||||
"queue_count": queue_entries.len(),
|
||||
"imaging_count": imaging,
|
||||
"waiting_count": waiting,
|
||||
"ipxe_assets": openpxe_ipxe_assets::list_assets(),
|
||||
@@ -591,7 +683,8 @@ async fn api_put_settings(
|
||||
"cannot enable Windows: 'wimboot' binary is not bundled. \
|
||||
Place a signed wimboot build at assets/ipxe/wimboot and rebuild \
|
||||
the container. See docs/architecture.md for details.",
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
new.smb_host_override = new.smb_host_override.trim().to_string();
|
||||
@@ -604,9 +697,15 @@ async fn api_put_settings(
|
||||
|
||||
if let Some(smb) = &state.smb {
|
||||
match (was_enabled, want_enabled) {
|
||||
(false, true) => { let _ = smb.start(); }
|
||||
(true, false) => { smb.stop(); }
|
||||
(true, true) => { let _ = smb.reconcile(); }
|
||||
(false, true) => {
|
||||
let _ = smb.start();
|
||||
}
|
||||
(true, false) => {
|
||||
smb.stop();
|
||||
}
|
||||
(true, true) => {
|
||||
let _ = smb.reconcile();
|
||||
}
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
@@ -623,7 +722,7 @@ async fn api_list_queue(State(state): State<AppState>) -> Json<serde_json::Value
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GateJoinParams {
|
||||
struct QueueJoinParams {
|
||||
/// Client MAC from iPXE's `${mac}` variable. iPXE substitutes before
|
||||
/// the HTTP request so we receive a plain colon-separated MAC.
|
||||
mac: Option<String>,
|
||||
@@ -634,7 +733,7 @@ struct GateJoinParams {
|
||||
/// until poll returns an actual boot script.
|
||||
async fn api_queue_join(
|
||||
State(state): State<AppState>,
|
||||
Query(p): Query<GateJoinParams>,
|
||||
Query(p): Query<QueueJoinParams>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let mac = p.mac.unwrap_or_else(|| "unknown".to_string());
|
||||
@@ -644,10 +743,14 @@ async fn api_queue_join(
|
||||
.and_then(|s| s.split(',').next())
|
||||
.and_then(|s| s.trim().parse().ok());
|
||||
|
||||
let gate = state.queue.join(&mac, ip, None);
|
||||
let queue_entry = state.queue.join(&mac, ip, None);
|
||||
state.clients.record(
|
||||
&mac, ip, None,
|
||||
ClientEvent::HttpScriptFetch { target: "queue-join".into() },
|
||||
&mac,
|
||||
ip,
|
||||
None,
|
||||
ClientEvent::HttpScriptFetch {
|
||||
target: "queue-join".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let base = &state.public_base_url;
|
||||
@@ -656,12 +759,12 @@ async fn api_queue_join(
|
||||
"#!ipxe\n\
|
||||
echo\n\
|
||||
echo ==========================================\n\
|
||||
echo Queued Deployment - Gate Position {}\n\
|
||||
echo Queued Deployment - Queue Position {}\n\
|
||||
echo Waiting for operator to assign an image\n\
|
||||
echo (Ctrl-B returns to the iPXE shell)\n\
|
||||
echo ==========================================\n\
|
||||
chain {base}/api/queue/poll/{}\n",
|
||||
gate.position, gate.id
|
||||
queue_entry.position, queue_entry.id
|
||||
);
|
||||
text_plain(script)
|
||||
}
|
||||
@@ -674,7 +777,7 @@ async fn api_queue_poll(
|
||||
AxumPath(entry_id): AxumPath<String>,
|
||||
) -> Response {
|
||||
let Some(notify) = state.queue.notifier(&entry_id) else {
|
||||
// Gate was released; send client back to the main menu.
|
||||
// Queue entry was released; send client back to the main menu.
|
||||
let base = &state.public_base_url;
|
||||
return text_plain(format!("#!ipxe\nchain {base}/boot.ipxe\n"));
|
||||
};
|
||||
@@ -687,7 +790,7 @@ async fn api_queue_poll(
|
||||
match snap {
|
||||
// Bind `target` directly so we can't observe an Option::None between
|
||||
// the guard and the unwrap (the old code had a race with concurrent
|
||||
// `release`). We also do NOT release the gate here — the web UI
|
||||
// `release`). We also do NOT release the queue entry here — the web UI
|
||||
// operator releases it explicitly, which keeps a record of "this
|
||||
// machine was assigned image X" visible until the client is known
|
||||
// to have started. Clients that retry on transient network errors
|
||||
@@ -698,20 +801,20 @@ async fn api_queue_poll(
|
||||
tracing::info!(
|
||||
target: "openpxe::queue",
|
||||
entry_id=%entry_id, mac=%g.mac, target=%target,
|
||||
"gate assignment delivered"
|
||||
"queue assignment delivered"
|
||||
);
|
||||
text_plain(format!(
|
||||
"#!ipxe\n\
|
||||
echo Gate assignment received: {target}\n\
|
||||
echo Queue assignment received: {target}\n\
|
||||
chain {base}/boot/{target}.ipxe || chain {base}/api/queue/poll/{entry_id}\n"
|
||||
))
|
||||
}
|
||||
Some(g) => {
|
||||
// No assignment yet - loop and re-poll. Repaint position so the
|
||||
// UI count stays accurate if other gates were released meanwhile.
|
||||
// UI count stays accurate if other queue entries were released meanwhile.
|
||||
text_plain(format!(
|
||||
"#!ipxe\n\
|
||||
echo Gate Position {} - still waiting\n\
|
||||
echo Queue Position {} - still waiting\n\
|
||||
chain {base}/api/queue/poll/{entry_id}\n",
|
||||
g.position
|
||||
))
|
||||
@@ -721,27 +824,34 @@ async fn api_queue_poll(
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GateAssignBody {
|
||||
struct QueueAssignBody {
|
||||
/// Boot entry id (from `BootEntry::id`). Same one used in
|
||||
/// `/boot/<id>.ipxe`.
|
||||
target: String,
|
||||
/// Gate ids to assign. Empty = assign to all currently queued gates.
|
||||
/// Queue entry ids to assign. Empty = assign to all currently queued clients.
|
||||
entry_ids: Vec<String>,
|
||||
}
|
||||
|
||||
async fn api_queue_assign(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<GateAssignBody>,
|
||||
Json(body): Json<QueueAssignBody>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let ids = if body.entry_ids.is_empty() {
|
||||
state.queue.list().into_iter().map(|g| g.id).collect::<Vec<_>>()
|
||||
state
|
||||
.queue
|
||||
.list()
|
||||
.into_iter()
|
||||
.map(|g| g.id)
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
body.entry_ids
|
||||
};
|
||||
// Guard: target must exist as a BootEntry id.
|
||||
let found = state.iso_store.list().into_iter().any(|i| {
|
||||
i.boot_entries.iter().any(|e| e.id == body.target)
|
||||
});
|
||||
let found = state
|
||||
.iso_store
|
||||
.list()
|
||||
.into_iter()
|
||||
.any(|i| i.boot_entries.iter().any(|e| e.id == body.target));
|
||||
if !found {
|
||||
return Json(json!({ "ok": false, "error": "unknown target" }));
|
||||
}
|
||||
@@ -765,10 +875,7 @@ async fn api_nfs_list(State(state): State<AppState>) -> Json<serde_json::Value>
|
||||
Json(json!({ "mounts": state.nfs.list() }))
|
||||
}
|
||||
|
||||
async fn api_nfs_add(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<NfsAddRequest>,
|
||||
) -> Response {
|
||||
async fn api_nfs_add(State(state): State<AppState>, Json(req): Json<NfsAddRequest>) -> Response {
|
||||
match state.nfs.add(req).await {
|
||||
Ok(m) => (StatusCode::CREATED, Json(m)).into_response(),
|
||||
// Anything from the manager surfaces as a user-fixable validation
|
||||
@@ -779,20 +886,14 @@ async fn api_nfs_add(
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_nfs_remove(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
async fn api_nfs_remove(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
|
||||
match state.nfs.remove(&id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn api_nfs_scan(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<String>,
|
||||
) -> Response {
|
||||
async fn api_nfs_scan(State(state): State<AppState>, AxumPath(id): AxumPath<String>) -> Response {
|
||||
match state.nfs.rescan(&id).await {
|
||||
Ok(n) => Json(json!({ "ok": true, "iso_count": n })).into_response(),
|
||||
Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
|
||||
@@ -899,11 +1000,14 @@ async fn api_metrics(State(state): State<AppState>) -> Response {
|
||||
state
|
||||
.metrics
|
||||
.set_client_count(state.clients.list().len() as u64);
|
||||
let gates = state.queue.list();
|
||||
let imaging = gates.iter().filter(|g| g.assigned_target.is_some()).count();
|
||||
let queue_entries = state.queue.list();
|
||||
let imaging = queue_entries
|
||||
.iter()
|
||||
.filter(|entry| entry.assigned_target.is_some())
|
||||
.count();
|
||||
state
|
||||
.metrics
|
||||
.set_queue_counts(gates.len() as u64, imaging as u64);
|
||||
.set_queue_counts(queue_entries.len() as u64, imaging as u64);
|
||||
state
|
||||
.metrics
|
||||
.set_nfs_active(state.nfs.list().iter().filter(|m| m.mounted).count() as u64);
|
||||
@@ -927,25 +1031,39 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn range_full() {
|
||||
let (s, e, p) = parse_range(None, 1000);
|
||||
let (s, e, p) = parse_range(None, 1000).unwrap();
|
||||
assert_eq!((s, e, p), (0, 999, false));
|
||||
}
|
||||
#[test]
|
||||
fn range_open_ended() {
|
||||
let h = HeaderValue::from_static("bytes=500-");
|
||||
let (s, e, p) = parse_range(Some(&h), 1000);
|
||||
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
||||
assert_eq!((s, e, p), (500, 999, true));
|
||||
}
|
||||
#[test]
|
||||
fn range_suffix() {
|
||||
let h = HeaderValue::from_static("bytes=-100");
|
||||
let (s, e, p) = parse_range(Some(&h), 1000);
|
||||
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
||||
assert_eq!((s, e, p), (900, 999, true));
|
||||
}
|
||||
#[test]
|
||||
fn range_explicit() {
|
||||
let h = HeaderValue::from_static("bytes=10-99");
|
||||
let (s, e, p) = parse_range(Some(&h), 1000);
|
||||
let (s, e, p) = parse_range(Some(&h), 1000).unwrap();
|
||||
assert_eq!((s, e, p), (10, 99, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_rejects_out_of_bounds_start() {
|
||||
let h = HeaderValue::from_static("bytes=1000-");
|
||||
let got = parse_range(Some(&h), 1000);
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_rejects_start_after_end() {
|
||||
let h = HeaderValue::from_static("bytes=99-10");
|
||||
let got = parse_range(Some(&h), 1000);
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user