Skip to main content

netget/client/yarn/
actions.rs

1//! YARN client: what the model is told and what it may do.
2//!
3//! Every action names a resource of the Hadoop YARN ResourceManager REST API by structured
4//! values — an application id, a node id, application states, a queue, a user — and [`request`]
5//! turns it into an [`ApiCall`]: path segments under `/ws/v1/cluster/`, a query string and, for
6//! the one write, a fixed JSON body. Each value is checked against the shape YARN gives it. The
7//! model never writes a URL, a path or a body; the host is fixed when the client connects.
8
9use std::sync::LazyLock;
10
11use anyhow::{bail, ensure, Context, Result};
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Map, Value};
14
15use crate::llm::actions::client_trait::{Client, ClientActionResult};
16use crate::llm::actions::protocol_trait::Protocol;
17use crate::llm::actions::{ActionDefinition, Parameter, ParameterDefinition, StartupExamples};
18use crate::protocol::log_template::LogTemplate;
19use crate::protocol::{ConnectContext, EventType};
20use crate::state::app_state::AppState;
21
22pub struct YarnClientProtocol;
23
24impl YarnClientProtocol {
25    pub fn new() -> Self {
26        Self
27    }
28}
29
30impl Default for YarnClientProtocol {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36/// Every application state YARN defines (`YarnApplicationState`).
37pub const APP_STATES: &[&str] = &[
38    "NEW",
39    "NEW_SAVING",
40    "SUBMITTED",
41    "ACCEPTED",
42    "RUNNING",
43    "FINISHED",
44    "FAILED",
45    "KILLED",
46];
47/// Every final status YARN defines (`FinalApplicationStatus`).
48pub const FINAL_STATUSES: &[&str] = &["UNDEFINED", "SUCCEEDED", "FAILED", "KILLED", "ENDED"];
49/// Every node state YARN defines (`NodeState`).
50pub const NODE_STATES: &[&str] = &[
51    "NEW",
52    "RUNNING",
53    "UNHEALTHY",
54    "DECOMMISSIONED",
55    "DECOMMISSIONING",
56    "LOST",
57    "REBOOTED",
58    "SHUTDOWN",
59];
60
61fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
62    Parameter {
63        name: name.to_string(),
64        type_hint: type_hint.to_string(),
65        description: description.to_string(),
66        required,
67    }
68}
69
70fn action(
71    name: &str,
72    description: &str,
73    parameters: Vec<Parameter>,
74    example: Value,
75) -> ActionDefinition {
76    ActionDefinition {
77        name: name.to_string(),
78        description: description.to_string(),
79        parameters,
80        example,
81        log_template: Some(LogTemplate::new().with_info(format!("-> YARN {name} {{app_id}}"))),
82    }
83}
84
85fn app_id_param() -> Parameter {
86    p(
87        "app_id",
88        "string",
89        "The application id exactly as YARN reports it: application_<cluster timestamp>_<sequence>, \
90         e.g. application_1792047302594_0003",
91        true,
92    )
93}
94
95fn max_items_param() -> Parameter {
96    p(
97        "max_items",
98        "number",
99        &format!(
100            "How many entries to deliver at most (default {}, at most {}); truncated says \
101             whether more existed",
102            super::DEFAULT_MAX_ITEMS,
103            super::MAX_ITEMS_LIMIT
104        ),
105        false,
106    )
107}
108
109pub fn actions() -> Vec<ActionDefinition> {
110    vec![
111        action(
112            "get_cluster_info",
113            "The ResourceManager's identity: its version, cluster id, start time, state and HA state (GET /ws/v1/cluster/info). Answered by yarn_cluster_info.",
114            vec![],
115            json!({"type": "get_cluster_info"}),
116        ),
117        action(
118            "get_cluster_metrics",
119            "The cluster's totals: applications by state, memory and vcores total/allocated/available, containers, nodes by state (GET /ws/v1/cluster/metrics). Answered by yarn_cluster_metrics.",
120            vec![],
121            json!({"type": "get_cluster_metrics"}),
122        ),
123        action(
124            "get_scheduler",
125            "The scheduler and its queues, each with capacity, used capacity and application counts (GET /ws/v1/cluster/scheduler). Answered by yarn_scheduler.",
126            vec![max_items_param()],
127            json!({"type": "get_scheduler"}),
128        ),
129        action(
130            "list_applications",
131            "Applications the ResourceManager knows, optionally filtered (GET /ws/v1/cluster/apps). Answered by yarn_applications.",
132            vec![
133                p("states", "string|array", &format!("Only applications in these states (one, or a list): {}", APP_STATES.join(", ")), false),
134                p("final_status", "string", &format!("Only applications with this final status: {}", FINAL_STATUSES.join(", ")), false),
135                p("queue", "string", "Only applications in this queue (default or root.default)", false),
136                p("user", "string", "Only applications of this user", false),
137                p("application_types", "string|array", "Only applications of these types (one, or a list): YARN, MAPREDUCE, SPARK, ...", false),
138                p("limit", "number", "How many applications the server returns at most (1..=10000)", false),
139                p("started_time_begin", "number", "Started at or after, epoch milliseconds", false),
140                p("started_time_end", "number", "Started at or before, epoch milliseconds", false),
141                p("finished_time_begin", "number", "Finished at or after, epoch milliseconds", false),
142                p("finished_time_end", "number", "Finished at or before, epoch milliseconds", false),
143                max_items_param(),
144            ],
145            json!({"type": "list_applications", "states": ["RUNNING", "ACCEPTED"], "queue": "default"}),
146        ),
147        action(
148            "get_application",
149            "One application: state, final status, progress, diagnostics, queue, resources used (GET /ws/v1/cluster/apps/{app_id}). Answered by yarn_applications.",
150            vec![app_id_param()],
151            json!({"type": "get_application", "app_id": "application_1792047302594_0003"}),
152        ),
153        action(
154            "list_app_attempts",
155            "An application's attempts, each with its AM container, node and state (GET /ws/v1/cluster/apps/{app_id}/appattempts). Answered by yarn_app_attempts.",
156            vec![app_id_param(), max_items_param()],
157            json!({"type": "list_app_attempts", "app_id": "application_1792047302594_0003"}),
158        ),
159        action(
160            "get_app_state",
161            "Just an application's state (GET /ws/v1/cluster/apps/{app_id}/state). Answered by yarn_app_state.",
162            vec![app_id_param()],
163            json!({"type": "get_app_state", "app_id": "application_1792047302594_0003"}),
164        ),
165        action(
166            "get_app_queue",
167            "Just the queue an application runs in (GET /ws/v1/cluster/apps/{app_id}/queue). Answered by yarn_app_queue.",
168            vec![app_id_param()],
169            json!({"type": "get_app_queue", "app_id": "application_1792047302594_0003"}),
170        ),
171        action(
172            "list_nodes",
173            "The cluster's NodeManagers with their state, health and resources (GET /ws/v1/cluster/nodes). Answered by yarn_nodes.",
174            vec![
175                p("states", "string|array", &format!("Only nodes in these states (one, or a list): {}", NODE_STATES.join(", ")), false),
176                max_items_param(),
177            ],
178            json!({"type": "list_nodes", "states": "RUNNING"}),
179        ),
180        action(
181            "get_node",
182            "One NodeManager (GET /ws/v1/cluster/nodes/{node_id}). Answered by yarn_nodes.",
183            vec![p("node_id", "string", "The node id exactly as list_nodes reports it: host:port, e.g. worker1.example.com:8041", true)],
184            json!({"type": "get_node", "node_id": "worker1.example.com:8041"}),
185        ),
186        action(
187            "kill_application",
188            "Kill an application (PUT /ws/v1/cluster/apps/{app_id}/state with {\"state\": \"KILLED\"}). Refused unless the client was opened with the startup parameter allow_kill: true. YARN answers 202 with an in-progress state (KILLING, or FINAL_SAVING while it records the outcome) and 200 KILLED once done. Answered by yarn_app_state.",
189            vec![app_id_param()],
190            json!({"type": "kill_application", "app_id": "application_1792047302594_0003"}),
191        ),
192        action(
193            "disconnect",
194            "Stop this client. The REST API keeps no session, so nothing is sent.",
195            vec![],
196            json!({"type": "disconnect"}),
197        ),
198    ]
199}
200
201fn ev(id: &str, description: &str, params: Vec<Parameter>) -> EventType {
202    EventType::new(
203        id,
204        description,
205        json!({"type": "list_applications", "states": "RUNNING"}),
206    )
207    .with_parameters(params)
208    .with_actions(actions())
209}
210
211fn op_param() -> Parameter {
212    p(
213        "operation",
214        "string",
215        "The action this answers, e.g. list_applications or get_application",
216        true,
217    )
218}
219
220fn counted(mut params: Vec<Parameter>) -> Vec<Parameter> {
221    params.insert(0, op_param());
222    params.extend([
223        p("count", "number", "How many entries are delivered", true),
224        p(
225            "total",
226            "number",
227            "How many entries the server returned",
228            true,
229        ),
230        p(
231            "truncated",
232            "boolean",
233            "True when the server returned more than max_items",
234            true,
235        ),
236    ]);
237    params
238}
239
240fn app_param() -> Parameter {
241    p("app_id", "string", "The application asked about", true)
242}
243
244pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
245    ev(
246        "yarn_connected",
247        "The client reached a YARN ResourceManager REST API and read its cluster info; nothing else has been asked.",
248        vec![
249            p("endpoint", "string", "The base URL every request goes to", true),
250            p("resource_manager_version", "string", "The ResourceManager's version", true),
251            p("cluster_id", "number", "The cluster id (the ResourceManager's start time, epoch ms)", false),
252            p("state", "string", "The ResourceManager's service state (STARTED)", false),
253            p("ha_state", "string", "ACTIVE or STANDBY. A standby ResourceManager redirects most requests to the active one, and this client does not follow redirects", false),
254            p("kill_allowed", "boolean", "Whether kill_application is allowed on this client", true),
255        ],
256    )
257});
258
259pub static CLUSTER_INFO_EVENT: LazyLock<EventType> = LazyLock::new(|| {
260    ev(
261        "yarn_cluster_info",
262        "The ResourceManager's identity, from get_cluster_info.",
263        vec![
264            op_param(),
265            p("cluster_info", "object", "{id, startedOn, state, haState, resourceManagerVersion, resourceManagerBuildVersion, hadoopVersion, rmStateStoreName, ...}", true),
266        ],
267    )
268});
269
270pub static METRICS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
271    ev(
272        "yarn_cluster_metrics",
273        "The cluster's totals, from get_cluster_metrics.",
274        vec![
275            op_param(),
276            p("metrics", "object", "{appsSubmitted, appsRunning, appsPending, appsCompleted, appsFailed, appsKilled, totalMB, allocatedMB, availableMB, pendingMB, totalVirtualCores, allocatedVirtualCores, availableVirtualCores, containersAllocated, containersPending, totalNodes, activeNodes, lostNodes, unhealthyNodes, decommissionedNodes, ...}", true),
277        ],
278    )
279});
280
281pub static SCHEDULER_EVENT: LazyLock<EventType> = LazyLock::new(|| {
282    ev(
283        "yarn_scheduler",
284        "The scheduler and its queues, from get_scheduler.",
285        counted(vec![
286            p("scheduler", "object", "{type (capacityScheduler, fairScheduler, fifoScheduler), queueName, capacity, usedCapacity, maxCapacity, ...}", true),
287            p("queues", "array", "Every queue below the root, parents before their children: {queueName, queuePath, queueType (parent/leaf), state, capacity, usedCapacity, maxCapacity, absoluteCapacity, absoluteUsedCapacity, numApplications, numActiveApplications, numPendingApplications, numContainers, ...}", true),
288        ]),
289    )
290});
291
292pub static APPLICATIONS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
293    ev(
294        "yarn_applications",
295        "Applications, from list_applications or get_application.",
296        counted(vec![
297            p("applications", "array", "One {id, name, user, queue, state, finalStatus, progress, applicationType, diagnostics, startedTime, launchTime, finishedTime, elapsedTime, allocatedMB, allocatedVCores, runningContainers, memorySeconds, vcoreSeconds, trackingUrl, amHostHttpAddress, masterNodeId, ...} per application", true),
298            p("filters", "object", "The filters list_applications sent", false),
299            p("app_id", "string", "The application get_application asked about", false),
300        ]),
301    )
302});
303
304pub static ATTEMPTS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
305    ev(
306        "yarn_app_attempts",
307        "An application's attempts, from list_app_attempts.",
308        counted(vec![
309            app_param(),
310            p("attempts", "array", "One {id (1, 2, ...), appAttemptId, appAttemptState, startTime, finishedTime, containerId (the AM container), nodeId, nodeHttpAddress, logsLink, diagnosticsInfo} per attempt", true),
311        ]),
312    )
313});
314
315pub static APP_STATE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
316    ev(
317        "yarn_app_state",
318        "An application's state, from get_app_state, or YARN's answer to kill_application (an in-progress state such as KILLING or FINAL_SAVING, then KILLED once done).",
319        vec![
320            op_param(),
321            app_param(),
322            p("state", "string", "The application's state as YARN reports it", true),
323            p("http_status", "number", "For kill_application: 202 while the kill is in progress, 200 once the application is in a final state", false),
324        ],
325    )
326});
327
328pub static APP_QUEUE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
329    ev(
330        "yarn_app_queue",
331        "The queue an application runs in, from get_app_queue.",
332        vec![
333            op_param(),
334            app_param(),
335            p(
336                "queue",
337                "string",
338                "The queue, as YARN names it (root.default)",
339                true,
340            ),
341        ],
342    )
343});
344
345pub static NODES_EVENT: LazyLock<EventType> = LazyLock::new(|| {
346    ev(
347        "yarn_nodes",
348        "NodeManagers, from list_nodes or get_node.",
349        counted(vec![
350            p("nodes", "array", "One {id (host:port), nodeHostName, nodeHTTPAddress, rack, state, healthReport, lastHealthUpdate, version, numContainers, usedMemoryMB, availMemoryMB, usedVirtualCores, availableVirtualCores} per node", true),
351            p("node_id", "string", "The node get_node asked about", false),
352        ]),
353    )
354});
355
356pub static ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
357    ev(
358        "yarn_error",
359        "A request failed: the ResourceManager answered with an error status (404 unknown application or node, 400 bad filter, 403 not allowed), redirected (a standby ResourceManager), or the answer could not be read (not JSON, too large, too deep, not YARN's shape); or kill_application was refused because the client was not opened with allow_kill. The client is still connected.",
360        vec![
361            op_param(),
362            p("app_id", "string", "The application it asked about, if any", false),
363            p("node_id", "string", "The node it asked about, if any", false),
364            p("http_status", "number", "The HTTP status of the response, when there was one", false),
365            p("exception", "string", "YARN's exception name (NotFoundException, BadRequestException, ...), when the server sent one", false),
366            p("message", "string", "What went wrong, in YARN's words (its RemoteException message) or this client's", true),
367        ],
368    )
369});
370
371/// One request to the REST API, built from a validated action. The URL is assembled from
372/// `segments` (each one a path segment under `/ws/v1/cluster/`, percent-encoded as one) and
373/// `query` on the client's fixed base URL, and nowhere else.
374#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
375pub struct ApiCall {
376    /// The action this came from.
377    pub operation: String,
378    /// `GET`, or `PUT` for kill_application.
379    pub method: String,
380    /// Path segments after `/ws/v1/cluster/`.
381    pub segments: Vec<String>,
382    /// Query parameters, in order.
383    pub query: Vec<(String, String)>,
384    /// The request body, for a PUT. Built here, never taken from the action.
385    pub body: Option<Value>,
386    /// Entries delivered at most.
387    pub max_items: usize,
388    /// The validated parameters, echoed into the event (app_id, node_id, filters, ...).
389    pub context: Map<String, Value>,
390}
391
392/// Validate a YARN application id: `application_<cluster timestamp>_<sequence>`, as
393/// `ApplicationId.fromString` reads it.
394pub fn app_id(raw: &str) -> Result<String> {
395    let id = raw.trim();
396    let shape_ok = id
397        .strip_prefix("application_")
398        .and_then(|rest| rest.split_once('_'))
399        .is_some_and(|(ts, seq)| {
400            (1..=19).contains(&ts.len())
401                && (1..=10).contains(&seq.len())
402                && ts.bytes().all(|b| b.is_ascii_digit())
403                && seq.bytes().all(|b| b.is_ascii_digit())
404        });
405    ensure!(
406        shape_ok,
407        "app_id {:?}: YARN application ids look like application_1792047302594_0003",
408        crate::utils::sanitize::line_field(&crate::utils::truncate::truncate_with_suffix(
409            id, 64, "..."
410        ))
411    );
412    Ok(id.to_string())
413}
414
415/// Validate a node id: `host:port`, as YARN's `NodeId` prints it.
416pub fn node_id(raw: &str) -> Result<String> {
417    let id = raw.trim();
418    let ok = id.rsplit_once(':').is_some_and(|(host, port)| {
419        !host.is_empty()
420            && host.len() <= 253
421            && !host.starts_with(['.', '-'])
422            && host
423                .bytes()
424                .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'_')
425            && (1..=5).contains(&port.len())
426            && port.bytes().all(|b| b.is_ascii_digit())
427            && port.parse::<u32>().is_ok_and(|n| n <= 65535)
428    });
429    ensure!(
430        ok,
431        "node_id {:?}: YARN node ids are host:port, e.g. worker1.example.com:8041",
432        crate::utils::sanitize::line_field(&crate::utils::truncate::truncate_with_suffix(
433            id, 64, "..."
434        ))
435    );
436    Ok(id.to_string())
437}
438
439/// A name YARN accepts as a queue (`default`, `root.default`) or an application type.
440fn name_filter(v: &Value, key: &str, max: usize) -> Result<String> {
441    let s = v.as_str().with_context(|| format!("{key} is text"))?.trim();
442    ensure!(
443        !s.is_empty()
444            && s.len() <= max
445            && !s.starts_with('.')
446            && s.bytes()
447                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')),
448        "{key} {:?}: 1 to {max} letters, digits, '.', '_' and '-'",
449        crate::utils::sanitize::line_field(&crate::utils::truncate::truncate_with_suffix(
450            s, 64, "..."
451        ))
452    );
453    Ok(s.to_string())
454}
455
456/// A user name as YARN reports it (`hadoop`, `dr.who`, `svc-etl@EXAMPLE.COM`).
457fn user_filter(v: &Value) -> Result<String> {
458    let s = v.as_str().context("user is text")?.trim();
459    ensure!(
460        !s.is_empty()
461            && s.len() <= 128
462            && s.bytes()
463                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'@' | b'$')),
464        "user {:?}: 1 to 128 letters, digits, '.', '_', '-', '@' and '$'",
465        crate::utils::sanitize::line_field(&crate::utils::truncate::truncate_with_suffix(
466            s, 64, "..."
467        ))
468    );
469    Ok(s.to_string())
470}
471
472/// One value or a list of them, each one of `allowed` (case-insensitive, sent upper-case).
473fn one_of_many(v: &Value, key: &str, allowed: &[&str]) -> Result<Vec<String>> {
474    let list: Vec<&Value> = match v {
475        Value::Array(items) => items.iter().collect(),
476        Value::String(s) if s.contains(',') => {
477            bail!("{key}: give a list rather than a comma-separated string")
478        }
479        other => vec![other],
480    };
481    ensure!(
482        !list.is_empty() && list.len() <= allowed.len(),
483        "{key}: one to {} of {}",
484        allowed.len(),
485        allowed.join(", ")
486    );
487    let mut out = Vec::new();
488    for item in list {
489        let s = item
490            .as_str()
491            .with_context(|| format!("{key} are text"))?
492            .trim()
493            .to_ascii_uppercase();
494        ensure!(
495            allowed.contains(&s.as_str()),
496            "{key}: {:?} is not one of {}",
497            crate::utils::sanitize::line_field(&crate::utils::truncate::truncate_with_suffix(
498                &s, 64, "..."
499            )),
500            allowed.join(", ")
501        );
502        if !out.contains(&s) {
503            out.push(s);
504        }
505    }
506    Ok(out)
507}
508
509/// An epoch-millisecond bound: a whole number, at most `i64::MAX`.
510fn epoch_ms(v: &Value, key: &str) -> Result<u64> {
511    let n = v
512        .as_u64()
513        .with_context(|| format!("{key} is epoch milliseconds, a whole number >= 0"))?;
514    ensure!(n <= i64::MAX as u64, "{key} {n} is out of range");
515    Ok(n)
516}
517
518fn get<'a>(v: &'a Value, key: &str) -> Option<&'a Value> {
519    v.get(key).filter(|x| !x.is_null())
520}
521
522fn op_name(v: &Value) -> &str {
523    v.get("type")
524        .and_then(Value::as_str)
525        .unwrap_or("the action")
526}
527
528fn max_items(v: &Value) -> Result<usize> {
529    let n = match get(v, "max_items") {
530        None => super::DEFAULT_MAX_ITEMS as u64,
531        Some(n) => n.as_u64().context("max_items is a whole number")?,
532    };
533    ensure!(
534        (1..=super::MAX_ITEMS_LIMIT as u64).contains(&n),
535        "max_items is 1..={}",
536        super::MAX_ITEMS_LIMIT
537    );
538    Ok(n as usize)
539}
540
541/// `["apps", app_id]`, with the id validated and echoed into the context.
542fn app_segments(v: &Value, ctx: &mut Map<String, Value>) -> Result<Vec<String>> {
543    let id = app_id(
544        get(v, "app_id")
545            .and_then(Value::as_str)
546            .with_context(|| format!("{} needs app_id (text)", op_name(v)))?,
547    )?;
548    ctx.insert("app_id".into(), json!(id));
549    Ok(vec!["apps".to_string(), id])
550}
551
552/// Validate an action and return the request it becomes.
553pub fn request(v: &Value) -> Result<ApiCall> {
554    let kind = v
555        .get("type")
556        .and_then(Value::as_str)
557        .context("an action needs a type")?;
558    let mut context = Map::new();
559    let mut query: Vec<(String, String)> = Vec::new();
560    let mut items = super::DEFAULT_MAX_ITEMS;
561    let mut method = "GET";
562    let mut body = None;
563    let segments: Vec<String> = match kind {
564        "get_cluster_info" => vec!["info".into()],
565        "get_cluster_metrics" => vec!["metrics".into()],
566        "get_scheduler" => {
567            items = max_items(v)?;
568            vec!["scheduler".into()]
569        }
570        "list_applications" => {
571            let mut filters = Map::new();
572            if let Some(s) = get(v, "states") {
573                let s = one_of_many(s, "states", APP_STATES)?;
574                filters.insert("states".into(), json!(s));
575                query.push(("states".into(), s.join(",")));
576            }
577            if let Some(s) = get(v, "final_status") {
578                let s = one_of_many(s, "final_status", FINAL_STATUSES)?;
579                ensure!(s.len() == 1, "final_status is one value");
580                filters.insert("final_status".into(), json!(s[0]));
581                query.push(("finalStatus".into(), s[0].clone()));
582            }
583            if let Some(q) = get(v, "queue") {
584                let q = name_filter(q, "queue", 255)?;
585                filters.insert("queue".into(), json!(q));
586                query.push(("queue".into(), q));
587            }
588            if let Some(u) = get(v, "user") {
589                let u = user_filter(u)?;
590                filters.insert("user".into(), json!(u));
591                query.push(("user".into(), u));
592            }
593            if let Some(t) = get(v, "application_types") {
594                let list: Vec<&Value> = match t {
595                    Value::Array(items) => items.iter().collect(),
596                    other => vec![other],
597                };
598                ensure!(
599                    (1..=10).contains(&list.len()),
600                    "application_types: one to 10 types"
601                );
602                let types = list
603                    .into_iter()
604                    .map(|x| name_filter(x, "application_types", 64))
605                    .collect::<Result<Vec<_>>>()?;
606                filters.insert("application_types".into(), json!(types));
607                query.push(("applicationTypes".into(), types.join(",")));
608            }
609            if let Some(limit) = get(v, "limit") {
610                let limit = limit.as_u64().context("limit is a whole number")?;
611                ensure!((1..=10_000).contains(&limit), "limit is 1..=10000");
612                filters.insert("limit".into(), json!(limit));
613                query.push(("limit".into(), limit.to_string()));
614            }
615            for (key, param) in [
616                ("started_time_begin", "startedTimeBegin"),
617                ("started_time_end", "startedTimeEnd"),
618                ("finished_time_begin", "finishedTimeBegin"),
619                ("finished_time_end", "finishedTimeEnd"),
620            ] {
621                if let Some(t) = get(v, key) {
622                    let t = epoch_ms(t, key)?;
623                    filters.insert(key.into(), json!(t));
624                    query.push((param.into(), t.to_string()));
625                }
626            }
627            context.insert("filters".into(), Value::Object(filters));
628            items = max_items(v)?;
629            vec!["apps".into()]
630        }
631        "get_application" => app_segments(v, &mut context)?,
632        "list_app_attempts" => {
633            let mut s = app_segments(v, &mut context)?;
634            items = max_items(v)?;
635            s.push("appattempts".into());
636            s
637        }
638        "get_app_state" => {
639            let mut s = app_segments(v, &mut context)?;
640            s.push("state".into());
641            s
642        }
643        "get_app_queue" => {
644            let mut s = app_segments(v, &mut context)?;
645            s.push("queue".into());
646            s
647        }
648        "kill_application" => {
649            let mut s = app_segments(v, &mut context)?;
650            s.push("state".into());
651            method = "PUT";
652            body = Some(json!({"state": "KILLED"}));
653            s
654        }
655        "list_nodes" => {
656            if let Some(s) = get(v, "states") {
657                let s = one_of_many(s, "states", NODE_STATES)?;
658                context.insert("states".into(), json!(s));
659                query.push(("states".into(), s.join(",")));
660            }
661            items = max_items(v)?;
662            vec!["nodes".into()]
663        }
664        "get_node" => {
665            let id = node_id(
666                get(v, "node_id")
667                    .and_then(Value::as_str)
668                    .context("get_node needs node_id (text)")?,
669            )?;
670            context.insert("node_id".into(), json!(id));
671            vec!["nodes".into(), id]
672        }
673        "disconnect" => Vec::new(),
674        other => bail!(
675            "Unknown YARN client action: {}",
676            crate::utils::sanitize::line_field(&crate::utils::truncate::truncate_with_suffix(
677                other, 64, "..."
678            ))
679        ),
680    };
681    Ok(ApiCall {
682        operation: kind.to_string(),
683        method: method.to_string(),
684        segments,
685        query,
686        body,
687        max_items: items,
688        context,
689    })
690}
691
692fn param(
693    name: &str,
694    type_hint: &str,
695    description: &str,
696    required: bool,
697    example: Value,
698    default: Option<Value>,
699) -> ParameterDefinition {
700    ParameterDefinition {
701        name: name.into(),
702        type_hint: type_hint.into(),
703        description: description.into(),
704        required,
705        example,
706        default,
707    }
708}
709
710impl Protocol for YarnClientProtocol {
711    fn protocol_name(&self) -> &'static str {
712        "YARN"
713    }
714    fn stack_name(&self) -> &'static str {
715        "ETH>IP>TCP>HTTP>YARN"
716    }
717    fn keywords(&self) -> Vec<&'static str> {
718        vec![
719            "yarn client",
720            "yarn resourcemanager client",
721            "hadoop yarn applications",
722            "yarn rest api",
723            "yarn cluster metrics",
724        ]
725    }
726    fn description(&self) -> &'static str {
727        "Hadoop YARN ResourceManager REST API client: reads cluster info and metrics, the scheduler's queues, applications (filtered by state, final status, queue, user, type), an application's attempts, state and queue, and NodeManagers; kills an application only when opened with allow_kill"
728    }
729    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
730        actions()
731    }
732    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
733        actions()
734    }
735    fn get_event_types(&self) -> Vec<EventType> {
736        vec![
737            CONNECTED_EVENT.clone(),
738            CLUSTER_INFO_EVENT.clone(),
739            METRICS_EVENT.clone(),
740            SCHEDULER_EVENT.clone(),
741            APPLICATIONS_EVENT.clone(),
742            ATTEMPTS_EVENT.clone(),
743            APP_STATE_EVENT.clone(),
744            APP_QUEUE_EVENT.clone(),
745            NODES_EVENT.clone(),
746            ERROR_EVENT.clone(),
747        ]
748    }
749    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
750        vec![
751            param(
752                "endpoint_url",
753                "string",
754                "The ResourceManager's web address, used exactly as given: http://rm.example.com:8088, or a gateway path such as https://gateway/yarn. Without it the client's remote_addr is the target (host:port means http://host:port). There is no default target",
755                false,
756                json!("http://127.0.0.1:8088"),
757                None,
758            ),
759            param(
760                "request_timeout_secs",
761                "number",
762                "Seconds one request may take (1..=600)",
763                false,
764                json!(30),
765                Some(json!(super::REQUEST_TIMEOUT.as_secs())),
766            ),
767            param(
768                "allow_kill",
769                "boolean",
770                "Allow kill_application on this client. Without it every kill is refused and nothing is sent; the client is read-only",
771                false,
772                json!(true),
773                None,
774            ),
775        ]
776    }
777    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
778        use crate::protocol::metadata::*;
779        ProtocolMetadataV2::builder()
780            .state(DevelopmentState::Beta)
781            .privilege_requirement(PrivilegeRequirement::None)
782            .implementation("Hadoop YARN ResourceManager REST API (/ws/v1/cluster) over HTTP(S), reqwest natively and the hyper transport in the browser build: GET info, metrics, scheduler (the queue tree walked iteratively), apps (states, finalStatus, queue, user, applicationTypes, limit, started/finished time bounds), one app, its appattempts, its state and its queue, nodes (states) and one node; PUT apps/{id}/state {\"state\":\"KILLED\"} only with the allow_kill startup parameter. Paths are built from validated ids, one percent-encoded segment each, on the base URL fixed at connect; the PUT body is fixed in Rust; redirects are not followed. Responses are bounded in size and JSON depth before parsing, unwrapped from YARN's envelopes and projected onto a fixed set of fields per resource")
783            .llm_control("Which applications, attempts, queues and nodes to read, with which filters and how many entries, and (with allow_kill) which application to kill; it reads the decoded entries and YARN's own RemoteException text")
784            .e2e_testing("tests/client/yarn/real_server_test.rs runs the official Apache Hadoop 3.5.0 distribution (SHA-512 pinned by install_peers.sh) as a single-node ResourceManager + NodeManager on loopback with the model mocked, and runs real distributed-shell applications on it: one that succeeds in queue default, one whose container exits non-zero in queue netget, and a long-running one the model then kills. Every application's id, name, type, user, queue, state and final status is checked against `yarn application -list -appStates ALL`, the failed one's start/finish times and diagnostics against `yarn application -status`, its attempt against `yarn applicationattempt -list`, the node against `yarn node -list -all`, the queues against capacity-scheduler.xml, and the kill against `yarn application -status` afterwards. A recording TCP relay between NetGet and the ResourceManager asserts the exact request lines (and the PUT body) YARN received for the model's actions and that nothing else was sent. YARN's own 404 for an unknown application, a kill refused without allow_kill (nothing on the wire), the follow-up bound and an LLM failure (the ResourceManager sees only the connect probe) run against the same peer. Fails rather than skips without NETGET_HADOOP_HOME")
785            .notes("Beta against the client bar: the peer is Apache Hadoop's own ResourceManager and NodeManager, the reference implementation, with real applications run on them; the suite fails rather than skips; each session is a real chain whose later requests are built from ids the model read out of earlier answers; and the client acting on the model's answer is asserted on the wire from the request lines the ResourceManager received, and for the kill from YARN's own CLI. Only Hadoop 3.5.0 is tested; the /ws/v1 surface read here is the documented one since Hadoop 2.x, and a field an older release lacks is simply absent from the projection. Not implemented: application submission (POST /apps/new-application and POST /apps), whose am-container-spec carries the shell command YARN runs on a cluster node — arbitrary code execution on a model's say-so, which a monitoring client should not offer; queue moves, priority and timeout updates, reservations, delegation tokens and the NodeManager's own /ws/v1/node API (a different service on each node). No SPNEGO/Kerberos: on a secured cluster every request answers 401 and the client reports it")
786            .build()
787    }
788    fn example_prompt(&self) -> &'static str {
789        "Connect to the YARN ResourceManager at http://127.0.0.1:8088 and tell me which applications failed today and why"
790    }
791    fn get_startup_examples(&self) -> StartupExamples {
792        let llm = json!({
793            "type": "open_client",
794            "protocol": "YARN",
795            "remote_addr": "127.0.0.1:8088",
796            "startup_params": {"request_timeout_secs": 30},
797            "instruction": "List the failed applications and read each one's diagnostics"
798        });
799        let mut static_example = llm.clone();
800        static_example["event_handlers"] = json!([
801            {"event_pattern": "yarn_connected", "handler": {"type": "static", "actions": [
802                {"type": "list_applications", "final_status": "FAILED", "limit": 20}
803            ]}},
804            {"event_pattern": "*", "handler": {"type": "static", "actions": []}}
805        ]);
806        let mut scripted = static_example.clone();
807        scripted["event_handlers"][1] = json!({"event_pattern": "yarn_applications", "handler": {
808            "type": "script", "language": "python",
809            "code": "import json,sys\ne=json.load(sys.stdin)['event']\na=[{'type':'list_app_attempts','app_id':x['id']} for x in e.get('applications',[])[:3]]\nprint(json.dumps({'actions':a}))"
810        }});
811        StartupExamples::new(llm, scripted, static_example)
812    }
813    fn group_name(&self) -> &'static str {
814        "Monitoring"
815    }
816}
817
818impl Client for YarnClientProtocol {
819    fn connect(
820        &self,
821        ctx: ConnectContext,
822    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
823    {
824        Box::pin(super::connect(ctx))
825    }
826    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
827        let call = request(&v)?;
828        if call.operation == "disconnect" {
829            return Ok(ClientActionResult::Disconnect);
830        }
831        Ok(ClientActionResult::Custom {
832            name: call.operation.clone(),
833            data: serde_json::to_value(&call)?,
834        })
835    }
836}