Skip to main content

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}