netget/server/yarn/mod.rs
1//! Hadoop YARN ResourceManager REST API server.
2//!
3//! Serves the ResourceManager web-service endpoints a real client / `curl` hits
4//! (`/ws/v1/cluster/info|metrics|apps|nodes`, an app by id). The LLM roleplays the
5//! cluster control plane, inventing applications, nodes and metrics per request.
6//!
7//! Static vs LLM-driven:
8//! - `GET /ws/v1/cluster/info` — the version banner is purely mechanical and answered
9//! **statically** here, with no LLM round-trip.
10//! - Anything unrecognised (not under `/ws/v1/cluster`) gets a static 404 RemoteException,
11//! again with no LLM call (keeps scanner noise off the model).
12//! - `metrics`, `scheduler`, `apps` (list + submit), `nodes`, app-by-id — **LLM-driven**.
13//!
14//! Fail-closed: on an LLM error, or when the model produces no `yarn_response`, the server
15//! answers 503/500 with a YARN RemoteException envelope — never a success-shaped empty
16//! cluster (which a client cannot distinguish from a genuinely idle cluster).
17
18pub mod actions;
19
20use std::net::SocketAddr;
21use std::sync::Arc;
22
23use bytes::Bytes;
24use http_body_util::{BodyExt, Full};
25use hyper::body::Incoming;
26use hyper::server::conn::http1;
27use hyper::service::service_fn;
28use hyper::{Request, Response};
29use hyper_util::rt::TokioIo;
30use tokio::sync::mpsc;
31use tracing::{error, trace};
32
33use crate::llm::ollama_client::OllamaClient;
34use crate::llm::ActionResult;
35use crate::logging::emit::Log;
36use crate::server::accept_bounded::{
37 accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
38};
39use crate::server::connection::ConnectionId;
40use crate::server::yarn::actions::YarnProtocol;
41use crate::state::app_state::AppState;
42use crate::{console_error, console_info};
43
44const JSON_CT: &str = "application/json";
45
46/// How long a peer that has produced nothing at all may hold this connection.
47///
48/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
49/// ResourceManager client sends it inside its dial path, so a peer that has connected and sent
50/// no byte has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
51/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
52///
53/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
54/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
55/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
56/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
57/// without consuming it, so the request line is still there for hyper afterwards.
58const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
59
60/// How long an established connection may sit **silent** between requests.
61///
62/// This bounds the silence and never a transfer. A request being answered holds
63/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
64/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
65/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
66/// ever runs on a connection with nothing in flight.
67///
68/// Three minutes. The YARN ResourceManager REST API is polled - a dashboard, a `yarn application
69/// -list`, a scheduler scrape - on a seconds-to-minutes cycle, so the gap this measures is a
70/// poller that has stopped polling rather than a client doing local work. Three minutes is well
71/// above any polling interval anyone configures and more than twice nginx's `keepalive_timeout`
72/// default of 75s; a poller whose connection was closed opens another, which is what RFC 9112
73/// requires of it.
74const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
75
76/// Concurrent connections this server admits.
77///
78/// Each admitted connection may hold one whole in-memory JSON response, so the cap is what turns
79/// that per-connection bound into a total one. A monitoring API's clients are a handful of
80/// pollers, so 256 is far above any real deployment and far below what socket exhaustion
81/// needs.
82const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
83
84/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
85///
86/// `503 Service Unavailable` with `Retry-After`, in YARN's own vocabulary:
87/// a 503 is what a REST client and a dashboard both already
88/// understand, and a human with `curl` reads it directly.
89/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
90/// reason, under `decision=fail_closed_connection_cap`.
91const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
92 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
93
94/// YARN ResourceManager REST server.
95pub struct YarnServer;
96
97impl YarnServer {
98 #[allow(clippy::too_many_arguments)]
99 pub async fn spawn_with_llm_actions(
100 listen_addr: SocketAddr,
101 llm_client: OllamaClient,
102 app_state: Arc<AppState>,
103 status_tx: mpsc::UnboundedSender<String>,
104 server_id: crate::state::ServerId,
105 rm_version: String,
106 cluster_id: String,
107 ) -> anyhow::Result<SocketAddr> {
108 let listener =
109 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
110 let local_addr = listener.local_addr()?;
111 console_info!(
112 status_tx,
113 "YARN ResourceManager listening on {}",
114 local_addr
115 );
116
117 let protocol = Arc::new(YarnProtocol::new());
118 let banner = Arc::new((rm_version, cluster_id));
119
120 let task_registrar = app_state.clone();
121 let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
122 let accept_handle = tokio::spawn(async move {
123 loop {
124 match accept_bounded(
125 &listener,
126 &limiter,
127 CONNECTION_CAP_REFUSAL,
128 "YARN",
129 Some(&status_tx),
130 )
131 .await
132 {
133 Ok((stream, remote_addr, permit)) => {
134 let connection_id =
135 ConnectionId::new(app_state.get_next_unified_id().await);
136 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
137 Log::new(Some(&status_tx)).info(format!(
138 "YARN connection {} from {}",
139 connection_id, remote_addr
140 ));
141
142 use crate::state::server::{
143 ConnectionState as ServerConnectionState, ConnectionStatus,
144 ProtocolConnectionInfo,
145 };
146 let now = crate::utils::clock::Instant::now();
147 let conn_state = ServerConnectionState {
148 id: connection_id,
149 remote_addr,
150 local_addr: local_addr_conn,
151 bytes_sent: 0,
152 bytes_received: 0,
153 packets_sent: 0,
154 packets_received: 0,
155 last_activity: now,
156 status: ConnectionStatus::Active,
157 status_changed_at: now,
158 protocol_info: ProtocolConnectionInfo::empty(),
159 };
160 app_state
161 .add_connection_to_server(server_id, conn_state)
162 .await;
163 let _ = status_tx.send("__UPDATE_UI__".to_string());
164
165 let llm_client_clone = llm_client.clone();
166 let app_state_clone = app_state.clone();
167 let status_tx_clone = status_tx.clone();
168 let protocol_clone = protocol.clone();
169 let banner_clone = banner.clone();
170
171 let task_owner = app_state.clone();
172 task_owner
173 .spawn_server_task(server_id, async move {
174 // Held for the life of the connection, so the
175 // cap counts live clients, not accepts.
176 let _permit = permit;
177
178 // The first-byte bound, before hyper sees the
179 // socket. `peek` waits for data without
180 // consuming it, so the request line is still
181 // there for hyper afterwards, and it bounds
182 // exactly the case that needs bounding: a peer
183 // that connected and said nothing at all.
184 let spoke = matches!(
185 tokio::time::timeout(
186 FIRST_BYTE_READ_TIMEOUT,
187 stream.peek(&mut [0u8; 1]),
188 )
189 .await,
190 Ok(Ok(n)) if n > 0
191 );
192
193 if spoke {
194 let io = TokioIo::new(stream);
195 let status_for_service = status_tx_clone.clone();
196 let app_state_for_service = app_state_clone.clone();
197
198 // Whether this connection is answering
199 // anything. The watchdog below reads it, so
200 // only genuine silence - never work in
201 // flight - can close a connection.
202 let activity = Arc::new(ConnectionActivity::new());
203 let activity_for_service = Arc::clone(&activity);
204 let service = service_fn(move |req: Request<Incoming>| {
205 let llm_clone = llm_client_clone.clone();
206 let state_clone = app_state_for_service.clone();
207 let status_clone = status_for_service.clone();
208 let protocol_clone = protocol_clone.clone();
209 let banner_clone = banner_clone.clone();
210 // Busy for the whole of this request - the
211 // model round-trip included, and a `manual`
212 // rule parked for a human - so the idle
213 // watchdog can never close the connection an
214 // answer belongs to.
215 let activity = Arc::clone(&activity_for_service);
216 async move {
217 let _busy = activity.busy();
218 handle_yarn_request(
219 req,
220 connection_id,
221 llm_clone,
222 state_clone,
223 status_clone,
224 protocol_clone,
225 server_id,
226 banner_clone,
227 )
228 .await
229 }
230 });
231
232 let conn = http1::Builder::new().serve_connection(io, service);
233 tokio::pin!(conn);
234 tokio::select! {
235 result = &mut conn => {
236 if let Err(err) = result {
237 error!("Error serving YARN connection: {:?}", err);
238 }
239 }
240 // The idle bound, over `ConnectionActivity`
241 // rather than over a read: hyper owns every
242 // read once `serve_connection` starts, and
243 // keeps polling for frames while a request is
244 // being answered, so a deadline on those reads
245 // would fire mid-answer. A connection with work
246 // in flight is not idle at all.
247 _ = watch_idle(
248 Arc::clone(&activity),
249 IDLE_BETWEEN_REQUESTS_TIMEOUT,
250 ) => {
251 Log::new(Some(&status_tx_clone)).debug(format!(
252 "YARN connection {} idle for {}s; closing",
253 connection_id,
254 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
255 ));
256 }
257 }
258 } else {
259 Log::new(Some(&status_tx_clone)).debug(format!(
260 "YARN peer {} sent no request within {}s; closing \
261 decision=fail_closed_first_byte_timeout",
262 remote_addr,
263 FIRST_BYTE_READ_TIMEOUT.as_secs()
264 ));
265 }
266
267 app_state_clone
268 .close_connection_on_server(server_id, connection_id)
269 .await;
270 Log::new(Some(&status_tx_clone))
271 .info(format!("YARN connection {} closed", connection_id));
272 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
273 })
274 .await;
275 }
276 Err(e) => {
277 console_error!(status_tx, "Failed to accept YARN connection: {}", e);
278 break;
279 }
280 }
281 }
282 });
283
284 task_registrar
285 .register_server_task(server_id, accept_handle)
286 .await;
287
288 Ok(local_addr)
289 }
290}
291
292/// How much of a request body is read before the request is refused with 413.
293///
294/// Deliberately a local constant rather than a reference to
295/// `http_common::handler::MAX_REQUEST_BODY_BYTES`, which carries the same value:
296/// `http_common` is gated on `#[cfg(any(feature = "http", ...))]` and `yarn = []`
297/// pulls none of those, so borrowing it broke `cargo check --no-default-features
298/// --features yarn` — exactly what CI's `single-feature` job exists to catch.
299/// A per-protocol constant is also what the decentralisation rule asks for.
300///
301/// `Incoming` has no default limit, so without a cap the buffer is whatever the peer
302/// chooses to send.
303pub const MAX_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024;
304
305/// Approximate the bytes this request cost on the wire.
306///
307/// hyper hands us a parsed `Request`, so the original head is gone; this
308/// reconstructs its size from the parts that survive. It is an estimate, and
309/// deliberately so — the alternative is a `↓` counter frozen at 0, which reads as
310/// "this peer sent nothing" rather than "we did not measure".
311fn approximate_request_bytes(req: &Request<Incoming>) -> u64 {
312 use hyper::body::Body;
313 let head: usize = req.method().as_str().len()
314 + req.uri().to_string().len()
315 + 12 // " HTTP/1.1\r\n" plus the blank line terminating the head
316 + req
317 .headers()
318 .iter()
319 .map(|(name, value)| name.as_str().len() + value.len() + 4)
320 .sum::<usize>();
321 head as u64 + req.body().size_hint().lower()
322}
323
324/// Record one request/response exchange against the connection's counters, then
325/// return the response unchanged.
326///
327/// `update_connection_stats` is what the dashboard rail's `↓ ↑` columns and the
328/// connection-scoped task prompts read, and it is what keeps `last_activity`
329/// moving. Without it a busy YARN server draws every peer as idle having sent
330/// and received nothing.
331#[allow(clippy::too_many_arguments)]
332async fn handle_yarn_request(
333 req: Request<Incoming>,
334 connection_id: ConnectionId,
335 llm_client: OllamaClient,
336 app_state: Arc<AppState>,
337 status_tx: mpsc::UnboundedSender<String>,
338 protocol: Arc<YarnProtocol>,
339 server_id: crate::state::ServerId,
340 banner: Arc<(String, String)>,
341) -> Result<Response<Full<Bytes>>, std::convert::Infallible> {
342 let bytes_received = approximate_request_bytes(&req);
343 let response = handle_yarn_request_inner(
344 req,
345 connection_id,
346 llm_client,
347 app_state.clone(),
348 status_tx,
349 protocol,
350 server_id,
351 banner,
352 )
353 .await;
354
355 let bytes_sent = response
356 .as_ref()
357 .ok()
358 .and_then(|resp| {
359 use hyper::body::Body;
360 resp.body().size_hint().exact()
361 })
362 .unwrap_or(0);
363 app_state
364 .update_connection_stats(
365 server_id,
366 connection_id,
367 Some(bytes_received),
368 Some(bytes_sent),
369 Some(1),
370 Some(1),
371 )
372 .await;
373
374 response
375}
376
377#[allow(clippy::too_many_arguments)]
378async fn handle_yarn_request_inner(
379 req: Request<Incoming>,
380 _connection_id: ConnectionId,
381 llm_client: OllamaClient,
382 app_state: Arc<AppState>,
383 status_tx: mpsc::UnboundedSender<String>,
384 protocol: Arc<YarnProtocol>,
385 server_id: crate::state::ServerId,
386 banner: Arc<(String, String)>,
387) -> Result<Response<Full<Bytes>>, std::convert::Infallible> {
388 let method = req.method().to_string();
389 let path = req.uri().path().to_string();
390
391 // Bounded. `POST /ws/v1/cluster/apps` carries a real body, `Incoming` has no
392 // default limit, and this body is buffered whole *and* interpolated into the LLM
393 // prompt below — so without a cap an unauthenticated submit of arbitrary length
394 // allocates twice over. `Limited` errors as soon as the cap is passed rather than
395 // after buffering the whole thing.
396 //
397 // Falling back to an empty body would be worse than refusing: the model would be
398 // asked to act on a submission it never saw, and would answer as if it had.
399 let body_bytes = match http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BODY_BYTES)
400 .collect()
401 .await
402 {
403 Ok(collected) => collected.to_bytes(),
404 Err(e) => {
405 console_error!(
406 status_tx,
407 "YARN {} {} decision=refused_body_too_large (limit {} bytes) -> 413: {}",
408 method,
409 path,
410 MAX_REQUEST_BODY_BYTES,
411 e
412 );
413 return Ok(build_yarn_response(
414 413,
415 yarn_remote_exception(
416 413,
417 "WebApplicationException",
418 &format!("request body exceeds {} bytes", MAX_REQUEST_BODY_BYTES),
419 ),
420 None,
421 ));
422 }
423 };
424 let body_str = String::from_utf8_lossy(&body_bytes).to_string();
425
426 let (operation, app_id, node_id) = detect_yarn_operation(&method, &path);
427 Log::new(Some(&status_tx)).debug(format!("YARN {} {} op={}", method, path, operation));
428 trace!("YARN request body: {}", body_str);
429
430 // Mechanical endpoints answered without an LLM call.
431 if operation == "info" {
432 return Ok(build_cluster_info(&banner));
433 }
434 if operation == "unknown" {
435 return Ok(build_yarn_response(
436 404,
437 yarn_remote_exception(404, "NotFoundException", &format!("unknown path: {path}")),
438 None,
439 ));
440 }
441
442 let event = crate::protocol::Event::new(
443 &actions::YARN_REQUEST_EVENT,
444 serde_json::json!({
445 "method": method,
446 "path": path,
447 "operation": operation,
448 "app_id": app_id,
449 "node_id": node_id,
450 "request_body": body_str,
451 }),
452 );
453
454 let llm_result = crate::llm::action_helper::call_llm(
455 &llm_client,
456 &app_state,
457 server_id,
458 None,
459 &event,
460 protocol.as_ref(),
461 )
462 .await;
463
464 match llm_result {
465 Ok(execution_result) => {
466 for result in execution_result.protocol_results {
467 if let ActionResult::Custom { name, data } = result {
468 if name == "yarn_response" {
469 let status =
470 data.get("status").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
471 let body = data
472 .get("body")
473 .and_then(|v| v.as_str())
474 .unwrap_or("")
475 .to_string();
476 let location = data
477 .get("location")
478 .and_then(|v| v.as_str())
479 .map(str::to_string);
480 Log::new(Some(&status_tx)).debug(format!("YARN -> {}", status));
481 trace!("YARN response body: {}", body);
482 return Ok(build_yarn_response_str(status, body, location));
483 }
484 }
485 }
486 // Fail-closed: the model answered but produced no YARN response. Do NOT fall
487 // through to a success-shaped empty cluster — that is indistinguishable from a
488 // real idle cluster and is the fail-open trap.
489 Log::new(Some(&status_tx))
490 .error("YARN: LLM returned no yarn_response action; answering 500");
491 Ok(build_yarn_response(
492 500,
493 yarn_remote_exception(
494 500,
495 "WebApplicationException",
496 "netget: model produced no YARN response",
497 ),
498 None,
499 ))
500 }
501 Err(e) => {
502 // Overload is transient/retryable -> 503; anything else -> 500. Both carry the
503 // RemoteException envelope every YARN client parses.
504 let overloaded = crate::llm::is_overload_error(&e);
505 let (status, exception) = if overloaded {
506 (503u16, "ServiceUnavailableException")
507 } else {
508 (500u16, "WebApplicationException")
509 };
510 error!("LLM error for YARN request (status {}): {}", status, e);
511 console_error!(status_tx, "YARN answering {} on LLM failure: {}", status, e);
512 let reason = crate::utils::WireFailure::classify(&e).prefixed_text();
513 Ok(build_yarn_response(
514 status,
515 yarn_remote_exception(status, exception, reason),
516 None,
517 ))
518 }
519 }
520}
521
522/// Answer `GET /ws/v1/cluster/info` statically (mechanical version banner, no LLM).
523fn build_cluster_info(banner: &(String, String)) -> Response<Full<Bytes>> {
524 let (version, cluster_id) = banner;
525 let started_on: u64 = cluster_id.parse().unwrap_or(1476912658570);
526 let body = serde_json::json!({
527 "clusterInfo": {
528 "id": started_on,
529 "startedOn": started_on,
530 "state": "STARTED",
531 "haState": "ACTIVE",
532 "rmStateStoreName":
533 "org.apache.hadoop.yarn.server.resourcemanager.recovery.NullRMStateStore",
534 "resourceManagerVersion": version,
535 "resourceManagerBuildVersion": format!("{version} from netget"),
536 "resourceManagerVersionBuiltOn": "2025-01-01T00:00Z",
537 "hadoopVersion": version,
538 "hadoopBuildVersion": format!("{version} from netget"),
539 "hadoopVersionBuiltOn": "2025-01-01T00:00Z",
540 "haZooKeeperConnectionState": "ResourceManager HA is not enabled."
541 }
542 });
543 build_yarn_response(200, body, None)
544}
545
546fn yarn_remote_exception(_status: u16, exception: &str, message: &str) -> serde_json::Value {
547 serde_json::json!({
548 "RemoteException": {
549 "exception": exception,
550 "message": message,
551 "javaClassName": format!("org.apache.hadoop.yarn.webapp.{exception}"),
552 }
553 })
554}
555
556/// Build a YARN JSON response from a serde value.
557fn build_yarn_response(
558 status: u16,
559 body: serde_json::Value,
560 location: Option<String>,
561) -> Response<Full<Bytes>> {
562 build_yarn_response_str(
563 status,
564 serde_json::to_string(&body).unwrap_or_default(),
565 location,
566 )
567}
568
569/// Build a YARN response from an already-serialized body string.
570///
571/// `status` originates in model output; `StatusCode::from_u16` rejects out-of-range values
572/// and the previous `.unwrap()` shape would panic inside the hyper task. An empty body (202
573/// Accepted on submit) is sent without a Content-Type.
574fn build_yarn_response_str(
575 status: u16,
576 body: String,
577 location: Option<String>,
578) -> Response<Full<Bytes>> {
579 let status = hyper::StatusCode::from_u16(status).unwrap_or_else(|_| {
580 error!("Invalid YARN status code {}, sending 500 instead", status);
581 hyper::StatusCode::INTERNAL_SERVER_ERROR
582 });
583
584 let mut builder = Response::builder().status(status);
585 if !body.is_empty() {
586 builder = builder.header("Content-Type", JSON_CT);
587 }
588 if let Some(loc) = location {
589 // Header values hyper rejects (e.g. CR/LF injection) are dropped rather than panicking.
590 if let Ok(v) = hyper::header::HeaderValue::from_str(&loc) {
591 builder = builder.header(hyper::header::LOCATION, v);
592 }
593 }
594 builder
595 .body(Full::new(Bytes::from(body)))
596 .unwrap_or_else(|_| Response::new(Full::new(Bytes::from("{}"))))
597}
598
599/// Map a YARN RM request to an operation name + optional app/node id.
600fn detect_yarn_operation(method: &str, path: &str) -> (String, Option<String>, Option<String>) {
601 let trimmed = path.trim_start_matches('/');
602 let parts: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
603
604 // Everything lives under ws/v1/cluster
605 match (method, parts.as_slice()) {
606 ("GET", ["ws", "v1", "cluster"]) => ("info".to_string(), None, None),
607 ("GET", ["ws", "v1", "cluster", "info"]) => ("info".to_string(), None, None),
608 ("GET", ["ws", "v1", "cluster", "metrics"]) => ("metrics".to_string(), None, None),
609 ("GET", ["ws", "v1", "cluster", "apps"]) => ("apps".to_string(), None, None),
610 ("POST", ["ws", "v1", "cluster", "apps", "new-application"]) => {
611 ("new_application".to_string(), None, None)
612 }
613 ("POST", ["ws", "v1", "cluster", "apps"]) => ("submit".to_string(), None, None),
614 ("GET", ["ws", "v1", "cluster", "apps", app_id]) => {
615 ("app".to_string(), Some(app_id.to_string()), None)
616 }
617 ("GET", ["ws", "v1", "cluster", "scheduler"]) => ("scheduler".to_string(), None, None),
618 ("GET", ["ws", "v1", "cluster", "nodes"]) => ("nodes".to_string(), None, None),
619 ("GET", ["ws", "v1", "cluster", "nodes", node_id]) => {
620 ("node".to_string(), None, Some(node_id.to_string()))
621 }
622 _ => ("unknown".to_string(), None, None),
623 }
624}