netget/server/spark/mod.rs
1//! Apache Spark monitoring REST API server.
2//!
3//! Serves the Spark monitoring endpoints a client / the History Server UI hits
4//! (`/api/v1/applications`, `.../{id}/jobs`, `/stages`, `/executors`). The LLM roleplays the
5//! application's control plane, inventing applications/jobs/stages/executors per request.
6//!
7//! Static vs LLM-driven:
8//! - `GET /api/v1/version` — mechanical version banner, answered **statically** (no LLM).
9//! - Unrecognised paths get a static 404 (plain text), no LLM call.
10//! - `applications`, `jobs`, `stages`, `executors` — **LLM-driven**.
11//!
12//! Fail-closed: on an LLM error, or when the model produces no `spark_response`, the server
13//! answers 503/500 with a JSON error object — never a success-shaped empty array (which a client
14//! cannot distinguish from a genuinely empty application list).
15
16pub mod actions;
17
18use std::net::SocketAddr;
19use std::sync::Arc;
20
21use bytes::Bytes;
22use http_body_util::{BodyExt, Full, Limited};
23use hyper::body::{Body, Incoming};
24use hyper::server::conn::http1;
25use hyper::service::service_fn;
26use hyper::{Request, Response};
27use hyper_util::rt::TokioIo;
28use tokio::sync::mpsc;
29use tracing::{error, trace, warn};
30
31use crate::llm::ollama_client::OllamaClient;
32use crate::llm::ActionResult;
33use crate::logging::emit::Log;
34use crate::server::accept_bounded::{
35 accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
36};
37use crate::server::connection::ConnectionId;
38use crate::server::spark::actions::SparkProtocol;
39use crate::state::app_state::AppState;
40use crate::{console_error, console_info};
41
42/// Narrow a model-supplied HTTP status to `u16` without wrapping.
43///
44/// `status as u16` on a `u64` truncates, and the truncation runs in the dangerous
45/// direction: `65736 as u16` is `200`, so a nonsense status becomes a success the client
46/// believes. Anything outside the real status range falls back to `default`.
47///
48/// This is `oauth2`'s `status_or` in Spark's vocabulary. The payoff here is a bogus
49/// monitoring answer rather than a credential, but the shape is the one the root
50/// `CLAUDE.md` catalogues under narrowing casts, and a monitoring client that records a
51/// fabricated `200` is exactly what a monitoring API must not do.
52fn status_or(value: Option<&serde_json::Value>, default: u16) -> u16 {
53 match value.and_then(|v| v.as_u64()) {
54 Some(raw) => u16::try_from(raw)
55 .ok()
56 .filter(|s| (100..=599).contains(s))
57 .unwrap_or_else(|| {
58 warn!("Spark: ignoring out-of-range status {raw}, using {default}");
59 default
60 }),
61 None => default,
62 }
63}
64
65/// Largest request body this server will buffer.
66///
67/// The monitoring API is read-only, so a body is never needed — but it was read with an
68/// unbounded `req.into_body().collect()` and then used for nothing but a `trace!`, which
69/// made an unauthenticated POST of any size a way to grow the process. Small on purpose.
70pub const MAX_REQUEST_BYTES: usize = 64 * 1024;
71
72/// How long a peer that has produced nothing at all may hold this connection.
73///
74/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
75/// monitoring client sends it inside its dial path, so a peer that has connected and sent no byte
76/// has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
77/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
78///
79/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
80/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
81/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
82/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
83/// without consuming it, so the request line is still there for hyper afterwards.
84const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
85
86/// How long an established connection may sit **silent** between requests.
87///
88/// This bounds the silence and never a transfer. A request being answered holds
89/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
90/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
91/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
92/// ever runs on a connection with nothing in flight.
93///
94/// Three minutes. The Spark monitoring API is read-only and polled - a dashboard, a metrics
95/// scrape, a `curl` - on a seconds-to-minutes cycle, so the silence this measures is a poller
96/// that has stopped rather than a client doing work. Three minutes is above any scrape interval
97/// anyone configures and more than twice nginx's `keepalive_timeout` default of 75s; a poller
98/// whose connection was closed opens another, as RFC 9112 requires of it.
99const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
100
101/// Concurrent connections this server admits.
102///
103/// Each admitted connection may hold one whole in-memory JSON response, so the cap is what turns
104/// that per-connection bound into a total one - the request side is already bounded at
105/// [`MAX_REQUEST_BYTES`]. A monitoring API's clients are a handful of pollers, so 256 is far
106/// above any real deployment and far below what socket exhaustion needs.
107const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
108
109/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
110///
111/// `503 Service Unavailable` with `Retry-After`, in Spark's own vocabulary:
112/// a 503 is what a monitoring client and a dashboard both already
113/// understand, and a human with `curl` reads it directly.
114/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
115/// reason, under `decision=fail_closed_connection_cap`.
116const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
117 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
118
119/// Apache Spark monitoring REST server.
120pub struct SparkServer;
121
122impl SparkServer {
123 pub async fn spawn_with_llm_actions(
124 listen_addr: SocketAddr,
125 llm_client: OllamaClient,
126 app_state: Arc<AppState>,
127 status_tx: mpsc::UnboundedSender<String>,
128 server_id: crate::state::ServerId,
129 spark_version: String,
130 ) -> anyhow::Result<SocketAddr> {
131 let listener =
132 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
133 let local_addr = listener.local_addr()?;
134 console_info!(status_tx, "Spark REST API listening on {}", local_addr);
135
136 let protocol = Arc::new(SparkProtocol::new());
137 let version = Arc::new(spark_version);
138
139 let task_registrar = app_state.clone();
140 let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
141 let accept_handle = tokio::spawn(async move {
142 loop {
143 match accept_bounded(
144 &listener,
145 &limiter,
146 CONNECTION_CAP_REFUSAL,
147 "SPARK",
148 Some(&status_tx),
149 )
150 .await
151 {
152 Ok((stream, remote_addr, permit)) => {
153 let connection_id =
154 ConnectionId::new(app_state.get_next_unified_id().await);
155 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
156 Log::new(Some(&status_tx)).info(format!(
157 "Spark connection {} from {}",
158 connection_id, remote_addr
159 ));
160
161 use crate::state::server::{
162 ConnectionState as ServerConnectionState, ConnectionStatus,
163 ProtocolConnectionInfo,
164 };
165 let now = crate::utils::clock::Instant::now();
166 let conn_state = ServerConnectionState {
167 id: connection_id,
168 remote_addr,
169 local_addr: local_addr_conn,
170 bytes_sent: 0,
171 bytes_received: 0,
172 packets_sent: 0,
173 packets_received: 0,
174 last_activity: now,
175 status: ConnectionStatus::Active,
176 status_changed_at: now,
177 protocol_info: ProtocolConnectionInfo::empty(),
178 };
179 app_state
180 .add_connection_to_server(server_id, conn_state)
181 .await;
182 let _ = status_tx.send("__UPDATE_UI__".to_string());
183
184 let llm_client_clone = llm_client.clone();
185 let app_state_clone = app_state.clone();
186 let status_tx_clone = status_tx.clone();
187 let protocol_clone = protocol.clone();
188 let version_clone = version.clone();
189
190 let task_owner = app_state.clone();
191 task_owner
192 .spawn_server_task(server_id, async move {
193 // Held for the life of the connection, so the
194 // cap counts live clients, not accepts.
195 let _permit = permit;
196
197 // The first-byte bound, before hyper sees the
198 // socket. `peek` waits for data without
199 // consuming it, so the request line is still
200 // there for hyper afterwards, and it bounds
201 // exactly the case that needs bounding: a peer
202 // that connected and said nothing at all.
203 let spoke = matches!(
204 tokio::time::timeout(
205 FIRST_BYTE_READ_TIMEOUT,
206 stream.peek(&mut [0u8; 1]),
207 )
208 .await,
209 Ok(Ok(n)) if n > 0
210 );
211
212 if spoke {
213 let io = TokioIo::new(stream);
214 let status_for_service = status_tx_clone.clone();
215 let app_state_for_service = app_state_clone.clone();
216
217 // Whether this connection is answering
218 // anything. The watchdog below reads it, so
219 // only genuine silence - never work in
220 // flight - can close a connection.
221 let activity = Arc::new(ConnectionActivity::new());
222 let activity_for_service = Arc::clone(&activity);
223 let service = service_fn(move |req: Request<Incoming>| {
224 let llm_clone = llm_client_clone.clone();
225 let state_clone = app_state_for_service.clone();
226 let status_clone = status_for_service.clone();
227 let protocol_clone = protocol_clone.clone();
228 let version_clone = version_clone.clone();
229 // Busy for the whole of this request - the
230 // model round-trip included, and a `manual`
231 // rule parked for a human - so the idle
232 // watchdog can never close the connection an
233 // answer belongs to.
234 let activity = Arc::clone(&activity_for_service);
235 async move {
236 let _busy = activity.busy();
237 handle_spark_request(
238 req,
239 connection_id,
240 llm_clone,
241 state_clone,
242 status_clone,
243 protocol_clone,
244 server_id,
245 version_clone,
246 )
247 .await
248 }
249 });
250
251 let conn = http1::Builder::new().serve_connection(io, service);
252 tokio::pin!(conn);
253 tokio::select! {
254 result = &mut conn => {
255 if let Err(err) = result {
256 error!("Error serving Spark connection: {:?}", err);
257 }
258 }
259 // The idle bound, over `ConnectionActivity`
260 // rather than over a read: hyper owns every
261 // read once `serve_connection` starts, and
262 // keeps polling for frames while a request is
263 // being answered, so a deadline on those reads
264 // would fire mid-answer. A connection with work
265 // in flight is not idle at all.
266 _ = watch_idle(
267 Arc::clone(&activity),
268 IDLE_BETWEEN_REQUESTS_TIMEOUT,
269 ) => {
270 Log::new(Some(&status_tx_clone)).debug(format!(
271 "Spark connection {} idle for {}s; closing",
272 connection_id,
273 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
274 ));
275 }
276 }
277 } else {
278 Log::new(Some(&status_tx_clone)).debug(format!(
279 "Spark peer {} sent no request within {}s; closing \
280 decision=fail_closed_first_byte_timeout",
281 remote_addr,
282 FIRST_BYTE_READ_TIMEOUT.as_secs()
283 ));
284 }
285
286 app_state_clone
287 .close_connection_on_server(server_id, connection_id)
288 .await;
289 Log::new(Some(&status_tx_clone))
290 .info(format!("Spark connection {} closed", connection_id));
291 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
292 })
293 .await;
294 }
295 Err(e) => {
296 console_error!(status_tx, "Failed to accept Spark connection: {}", e);
297 break;
298 }
299 }
300 }
301 });
302
303 task_registrar
304 .register_server_task(server_id, accept_handle)
305 .await;
306
307 Ok(local_addr)
308 }
309}
310
311/// Handle one monitoring-API request, then record what crossed the wire.
312///
313/// The rail's byte counters and the connection-scoped task prompts read
314/// `bytes_received`/`bytes_sent`, and this server left both at the zero it registered the
315/// connection with, so every Spark peer showed no traffic at all however much it moved.
316#[allow(clippy::too_many_arguments)]
317async fn handle_spark_request(
318 req: Request<Incoming>,
319 connection_id: ConnectionId,
320 llm_client: OllamaClient,
321 app_state: Arc<AppState>,
322 status_tx: mpsc::UnboundedSender<String>,
323 protocol: Arc<SparkProtocol>,
324 server_id: crate::state::ServerId,
325 version: Arc<String>,
326) -> Result<Response<Full<Bytes>>, std::convert::Infallible> {
327 let received = req.body().size_hint().lower();
328 let response = handle_spark_request_inner(
329 req,
330 llm_client,
331 app_state.clone(),
332 status_tx.clone(),
333 protocol,
334 server_id,
335 version,
336 )
337 .await;
338
339 let sent = response
340 .as_ref()
341 .ok()
342 .and_then(|resp| resp.body().size_hint().exact())
343 .unwrap_or(0);
344 app_state
345 .update_connection_stats(
346 server_id,
347 connection_id,
348 Some(received),
349 Some(sent),
350 Some(1),
351 Some(1),
352 )
353 .await;
354 let _ = status_tx.send("__UPDATE_UI__".to_string());
355
356 response
357}
358
359#[allow(clippy::too_many_arguments)]
360async fn handle_spark_request_inner(
361 req: Request<Incoming>,
362 llm_client: OllamaClient,
363 app_state: Arc<AppState>,
364 status_tx: mpsc::UnboundedSender<String>,
365 protocol: Arc<SparkProtocol>,
366 server_id: crate::state::ServerId,
367 version: Arc<String>,
368) -> Result<Response<Full<Bytes>>, std::convert::Infallible> {
369 let method = req.method().to_string();
370 let path = req.uri().path().to_string();
371
372 // The monitoring API takes no request body; this is read only so an unexpected one
373 // shows up in the trace log. An over-cap body is dropped rather than refused, because
374 // nothing downstream reads it and every endpoint here is a GET.
375 let body_bytes = match Limited::new(req.into_body(), MAX_REQUEST_BYTES)
376 .collect()
377 .await
378 {
379 Ok(collected) => collected.to_bytes(),
380 Err(e) => {
381 Log::new(Some(&status_tx)).warn(format!(
382 "Spark request body ignored (over {} bytes): {}",
383 MAX_REQUEST_BYTES, e
384 ));
385 Bytes::new()
386 }
387 };
388 let body_str = String::from_utf8_lossy(&body_bytes).to_string();
389
390 let (operation, app_id) = detect_spark_operation(&method, &path);
391 Log::new(Some(&status_tx)).debug(format!("Spark {} {} op={}", method, path, operation));
392 trace!("Spark request body: {}", body_str);
393
394 if operation == "version" {
395 // Answered by the server from a startup parameter; the model is never asked, so the
396 // tag says `static_answer` rather than claiming a `model_answer` nobody produced.
397 Log::new(Some(&status_tx)).debug(format!(
398 "Spark {} {} decision=static_answer: version banner, no LLM call",
399 method, path
400 ));
401 let body = serde_json::json!({ "spark": version.as_str() }).to_string();
402 return Ok(build_spark_response(200, body, "application/json"));
403 }
404 if operation == "unknown" {
405 Log::new(Some(&status_tx)).debug(format!(
406 "Spark {} {} decision=unknown_endpoint: 404, no LLM call",
407 method, path
408 ));
409 return Ok(build_spark_response(
410 404,
411 format!("no such endpoint: {path}"),
412 "text/plain",
413 ));
414 }
415
416 let event = crate::protocol::Event::new(
417 &actions::SPARK_REQUEST_EVENT,
418 serde_json::json!({
419 "method": method,
420 "path": path,
421 "operation": operation,
422 "app_id": app_id,
423 }),
424 );
425
426 let llm_result = crate::llm::action_helper::call_llm(
427 &llm_client,
428 &app_state,
429 server_id,
430 None,
431 &event,
432 protocol.as_ref(),
433 )
434 .await;
435
436 match llm_result {
437 Ok(execution_result) => {
438 for result in execution_result.protocol_results {
439 if let ActionResult::Custom { name, data } = result {
440 if name == "spark_response" {
441 // Two defects in one expression, and they compounded.
442 // `unwrap_or(200)` made an omitted status a success, so a model that
443 // produced a `spark_response` without deciding one told the client
444 // the job succeeded; and `as u16` narrowed without a range check, so
445 // `65736 as u16 == 200` reached that same success — the LDAP
446 // `result_code as u8` shape.
447 //
448 // `status_or` is oauth2's helper, shared so the idiom is greppable
449 // rather than reinvented. The default is **500, not 200**: a response
450 // the model did not finish describing is a server-side failure, and a
451 // monitoring client recording a fabricated success is exactly what a
452 // monitoring API must not do.
453 let status = status_or(data.get("status"), 500);
454 let mut body = data
455 .get("body")
456 .and_then(|v| v.as_str())
457 .unwrap_or("")
458 .to_string();
459 // `GET /api/v1/applications/{id}` is the one object in the API: Spark
460 // answers it `{id, name, attempts}`, not `[{...}]`. The model answers it
461 // with `send_spark_applications` and one element, which is sent here as
462 // that element.
463 if operation == "application" && status < 400 {
464 if let Some(one) = single_element(&body) {
465 body = one;
466 }
467 }
468 let ct = data
469 .get("content_type")
470 .and_then(|v| v.as_str())
471 .unwrap_or("application/json");
472 // A 4xx/5xx the model chose is a refusal it decided; a 2xx is an
473 // answer. Both came from the model, and neither is fail-closed.
474 let decision = if status >= 400 {
475 "model_reject"
476 } else {
477 "model_answer"
478 };
479 Log::new(Some(&status_tx)).info(format!(
480 "Spark {} {} op={} decision={}: answering {}",
481 method, path, operation, decision, status
482 ));
483 trace!("Spark response body: {}", body);
484 return Ok(build_spark_response(status, body, ct));
485 }
486 }
487 }
488 // Fail-closed: the model answered but produced no Spark response. A bare `[]` with
489 // 200 is a valid "no applications/jobs" result and a client cannot tell it from a
490 // backend that never ran — so answer 500 instead of that empty array.
491 Log::new(Some(&status_tx)).warn(format!(
492 "Spark {} {} op={} decision=model_silent: no spark_response action; answering \
493 500",
494 method, path, operation
495 ));
496 Ok(build_spark_error(
497 500,
498 "netget: model produced no Spark response",
499 ))
500 }
501 Err(e) => {
502 let (status, decision) = match crate::utils::WireFailure::classify(&e) {
503 crate::utils::WireFailure::Overloaded => (503u16, "fail_closed_llm_overloaded"),
504 crate::utils::WireFailure::Unavailable => (500u16, "fail_closed_llm_error"),
505 };
506 // One line carrying both sinks: the tag and the error go to `netget.log` and to
507 // the status stream the TUI and the test harness read. The *peer* gets only the
508 // category below.
509 Log::new(Some(&status_tx)).error(format!(
510 "Spark {} {} op={} decision={}: answering {}: {}",
511 method, path, operation, decision, status, e
512 ));
513 let reason = crate::utils::WireFailure::classify(&e).prefixed_text();
514 Ok(build_spark_error(status, reason))
515 }
516 }
517}
518
519/// The one element of a JSON array that holds exactly one object, re-serialised; `None` for
520/// anything else, which is then sent as the model gave it.
521fn single_element(body: &str) -> Option<String> {
522 match serde_json::from_str::<serde_json::Value>(body).ok()? {
523 serde_json::Value::Array(mut items) if items.len() == 1 && items[0].is_object() => {
524 serde_json::to_string(&items.remove(0)).ok()
525 }
526 _ => None,
527 }
528}
529
530/// Fail-closed JSON error body (distinct from any success array, which is a bare `[...]`).
531fn build_spark_error(status: u16, message: &str) -> Response<Full<Bytes>> {
532 let body = serde_json::json!({ "error": message, "status": status }).to_string();
533 build_spark_response(status, body, "application/json")
534}
535
536/// Build a Spark response. `status` originates in model output; `StatusCode::from_u16` rejects
537/// out-of-range values where the previous `.unwrap()` shape would panic inside the hyper task.
538fn build_spark_response(status: u16, body: String, content_type: &str) -> Response<Full<Bytes>> {
539 let status = hyper::StatusCode::from_u16(status).unwrap_or_else(|_| {
540 error!("Invalid Spark status code {}, sending 500 instead", status);
541 hyper::StatusCode::INTERNAL_SERVER_ERROR
542 });
543 Response::builder()
544 .status(status)
545 .header("Content-Type", content_type)
546 .body(Full::new(Bytes::from(body)))
547 .unwrap_or_else(|_| Response::new(Full::new(Bytes::from("[]"))))
548}
549
550/// Map a Spark monitoring-API request to an operation name + optional app id.
551fn detect_spark_operation(method: &str, path: &str) -> (String, Option<String>) {
552 let trimmed = path.trim_start_matches('/');
553 let parts: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
554
555 match (method, parts.as_slice()) {
556 ("GET", ["api", "v1", "version"]) => ("version".to_string(), None),
557 ("GET", ["api", "v1", "applications"]) => ("applications".to_string(), None),
558 ("GET", ["api", "v1", "applications", id]) => {
559 ("application".to_string(), Some(id.to_string()))
560 }
561 ("GET", ["api", "v1", "applications", id, "jobs"]) => {
562 ("jobs".to_string(), Some(id.to_string()))
563 }
564 ("GET", ["api", "v1", "applications", id, "stages"]) => {
565 ("stages".to_string(), Some(id.to_string()))
566 }
567 ("GET", ["api", "v1", "applications", id, "executors"]) => {
568 ("executors".to_string(), Some(id.to_string()))
569 }
570 // Read on every run by Datadog's Spark integration, which is how its absence was found:
571 // the model had no truthful way to answer it, only a 404 or a mislabelled array.
572 ("GET", ["api", "v1", "applications", id, "storage", "rdd"]) => {
573 ("rdd".to_string(), Some(id.to_string()))
574 }
575 // A per-application sub-resource we don't model explicitly still reaches the LLM as a
576 // generic per-application request so the model can answer or 404 it.
577 ("GET", ["api", "v1", "applications", id, _rest @ ..]) => {
578 ("application_resource".to_string(), Some(id.to_string()))
579 }
580 _ => ("unknown".to_string(), None),
581 }
582}