Skip to main content

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}