1use 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
36pub const APP_STATES: &[&str] = &[
38 "NEW",
39 "NEW_SAVING",
40 "SUBMITTED",
41 "ACCEPTED",
42 "RUNNING",
43 "FINISHED",
44 "FAILED",
45 "KILLED",
46];
47pub const FINAL_STATUSES: &[&str] = &["UNDEFINED", "SUCCEEDED", "FAILED", "KILLED", "ENDED"];
49pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
375pub struct ApiCall {
376 pub operation: String,
378 pub method: String,
380 pub segments: Vec<String>,
382 pub query: Vec<(String, String)>,
384 pub body: Option<Value>,
386 pub max_items: usize,
388 pub context: Map<String, Value>,
390}
391
392pub 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
415pub 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
439fn 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
456fn 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
472fn 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
509fn 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
541fn 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
552pub 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}