Skip to main content

netget/client/yarn/
decode.rs

1//! A ResourceManager REST response → the event the model reads.
2//!
3//! The ResourceManager answers every resource with JSON wrapped in an envelope named after it
4//! (`{"apps": {"app": [...]}}`, `{"node": {...}}`, `{"clusterMetrics": {...}}`), and each entry
5//! carries far more than a model can use — a node brings its resources three times over as
6//! nested `resourceInformations` maps, an application its `resourceSecondsMap` and `timeouts`.
7//! So each resource is **unwrapped and projected** onto a fixed list of fields, and nothing the
8//! server adds beyond them reaches the model.
9//!
10//! | resource | envelope | fields kept |
11//! |---|---|---|
12//! | cluster info | `clusterInfo` | [`CLUSTER_INFO_FIELDS`] |
13//! | cluster metrics | `clusterMetrics` | [`METRICS_FIELDS`] |
14//! | scheduler | `scheduler.schedulerInfo` | [`SCHEDULER_FIELDS`], and every queue (at most [`MAX_QUEUES`]) as [`QUEUE_FIELDS`] |
15//! | applications | `apps.app` (`null` when there are none) or `app` | [`APP_FIELDS`] |
16//! | application attempts | `appAttempts.appAttempt` | [`ATTEMPT_FIELDS`] |
17//! | application state / queue | `state` / `queue` | the one string |
18//! | nodes | `nodes.node` (`null` when there are none) or `node` | [`NODE_FIELDS`] |
19//!
20//! A list the server renders as a single object rather than an array (JAXB's "natural" JSON
21//! notation does that for a one-element list in some Hadoop releases) is read as a list of one.
22//!
23//! Every value kept is a scalar or a list of scalars. Text is stripped of control characters
24//! (it is a peer's, and it reaches a terminal) and cut at [`MAX_TEXT_BYTES`] —
25//! [`MAX_LONG_TEXT_BYTES`] for diagnostics and health reports, which are multi-line by nature.
26//!
27//! Before any of that, the body is bounded: at most `MAX_RESPONSE_BYTES` are read (while
28//! reading), and its bracket nesting is measured without recursion and refused past
29//! [`MAX_JSON_DEPTH`] before `serde_json` sees it. The scheduler's queue tree is walked with an
30//! explicit stack, not recursion, and stops at [`MAX_QUEUES`]. No function here calls itself.
31
32use anyhow::{bail, ensure, Result};
33use serde_json::{json, Map, Value};
34
35use crate::utils::sanitize;
36use crate::utils::truncate::truncate_with_suffix;
37
38/// Bracket nesting accepted in a response. The ResourceManager's deepest answer, the
39/// CapacityScheduler's queue tree, measured 16 levels against Hadoop 3.5.0 with one level of
40/// queues; each level below adds three (`queues`, `queue`, the entry), so 64 leaves room for a
41/// queue hierarchy fifteen deep.
42pub const MAX_JSON_DEPTH: usize = 64;
43/// A text field, single-line.
44pub const MAX_TEXT_BYTES: usize = 2048;
45/// Diagnostics and a node's health report.
46pub const MAX_LONG_TEXT_BYTES: usize = 8192;
47/// A list of scalars inside one entry.
48pub const MAX_SCALAR_LIST: usize = 1000;
49/// Queues walked in a scheduler answer.
50pub const MAX_QUEUES: usize = 500;
51
52pub const CLUSTER_INFO_FIELDS: &[&str] = &[
53    "id",
54    "startedOn",
55    "state",
56    "haState",
57    "haZooKeeperConnectionState",
58    "rmStateStoreName",
59    "resourceManagerVersion",
60    "resourceManagerBuildVersion",
61    "resourceManagerVersionBuiltOn",
62    "hadoopVersion",
63    "hadoopBuildVersion",
64    "hadoopVersionBuiltOn",
65];
66pub const METRICS_FIELDS: &[&str] = &[
67    "appsSubmitted",
68    "appsCompleted",
69    "appsPending",
70    "appsRunning",
71    "appsFailed",
72    "appsKilled",
73    "reservedMB",
74    "availableMB",
75    "allocatedMB",
76    "pendingMB",
77    "totalMB",
78    "utilizedMB",
79    "reservedVirtualCores",
80    "availableVirtualCores",
81    "allocatedVirtualCores",
82    "pendingVirtualCores",
83    "totalVirtualCores",
84    "utilizedVirtualCores",
85    "containersAllocated",
86    "containersReserved",
87    "containersPending",
88    "totalNodes",
89    "activeNodes",
90    "lostNodes",
91    "unhealthyNodes",
92    "decommissioningNodes",
93    "decommissionedNodes",
94    "rebootedNodes",
95    "shutdownNodes",
96];
97pub const SCHEDULER_FIELDS: &[&str] = &[
98    "type",
99    "queueName",
100    "queuePath",
101    "capacity",
102    "usedCapacity",
103    "maxCapacity",
104    // FifoScheduler
105    "qstate",
106    "minQueueMemoryCapacity",
107    "maxQueueMemoryCapacity",
108    "numNodes",
109    "usedNodeCapacity",
110    "availNodeCapacity",
111    "totalNodeCapacity",
112    "numContainers",
113];
114pub const QUEUE_FIELDS: &[&str] = &[
115    "queueName",
116    "queuePath",
117    "type",
118    "queueType",
119    "state",
120    "capacity",
121    "usedCapacity",
122    "maxCapacity",
123    "absoluteCapacity",
124    "absoluteMaxCapacity",
125    "absoluteUsedCapacity",
126    "weight",
127    "numApplications",
128    "numActiveApplications",
129    "numPendingApplications",
130    "numContainers",
131    "maxApplications",
132    "maxApplicationsPerUser",
133    "userLimit",
134    "userLimitFactor",
135    "defaultPriority",
136    // FairScheduler
137    "maxApps",
138    "numActiveApps",
139    "numPendingApps",
140    "schedulingPolicy",
141];
142pub const APP_FIELDS: &[&str] = &[
143    "id",
144    "user",
145    "name",
146    "queue",
147    "state",
148    "finalStatus",
149    "progress",
150    "trackingUI",
151    "trackingUrl",
152    "diagnostics",
153    "clusterId",
154    "applicationType",
155    "applicationTags",
156    "priority",
157    "startedTime",
158    "launchTime",
159    "finishedTime",
160    "elapsedTime",
161    "amContainerLogs",
162    "amHostHttpAddress",
163    "amRPCAddress",
164    "masterNodeId",
165    "allocatedMB",
166    "allocatedVCores",
167    "reservedMB",
168    "reservedVCores",
169    "runningContainers",
170    "memorySeconds",
171    "vcoreSeconds",
172    "queueUsagePercentage",
173    "clusterUsagePercentage",
174    "preemptedResourceMB",
175    "preemptedResourceVCores",
176    "numNonAMContainerPreempted",
177    "numAMContainerPreempted",
178    "logAggregationStatus",
179    "unmanagedApplication",
180    "appNodeLabelExpression",
181    "amNodeLabelExpression",
182];
183pub const ATTEMPT_FIELDS: &[&str] = &[
184    "id",
185    "appAttemptId",
186    "appAttemptState",
187    "startTime",
188    "finishedTime",
189    "containerId",
190    "nodeId",
191    "nodeHttpAddress",
192    "logsLink",
193    "blacklistedNodes",
194    "nodesBlacklistedBySystem",
195    "diagnosticsInfo",
196];
197pub const NODE_FIELDS: &[&str] = &[
198    "id",
199    "rack",
200    "state",
201    "nodeHostName",
202    "nodeHTTPAddress",
203    "version",
204    "lastHealthUpdate",
205    "healthReport",
206    "numContainers",
207    "usedMemoryMB",
208    "availMemoryMB",
209    "usedVirtualCores",
210    "availableVirtualCores",
211    "memUtilization",
212    "cpuUtilization",
213    "numRunningOpportContainers",
214    "numQueuedContainers",
215];
216
217/// Fields whose text is multi-line by nature and gets the longer bound.
218const LONG_TEXT_FIELDS: &[&str] = &["diagnostics", "diagnosticsInfo", "healthReport"];
219
220/// Which resource a response is.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum Resource {
223    ClusterInfo,
224    Metrics,
225    Scheduler,
226    Applications,
227    Attempts,
228    AppState,
229    AppQueue,
230    Nodes,
231}
232
233impl Resource {
234    pub fn of(operation: &str) -> Option<Resource> {
235        Some(match operation {
236            "get_cluster_info" => Resource::ClusterInfo,
237            "get_cluster_metrics" => Resource::Metrics,
238            "get_scheduler" => Resource::Scheduler,
239            "list_applications" | "get_application" => Resource::Applications,
240            "list_app_attempts" => Resource::Attempts,
241            "get_app_state" | "kill_application" => Resource::AppState,
242            "get_app_queue" => Resource::AppQueue,
243            "list_nodes" | "get_node" => Resource::Nodes,
244            _ => return None,
245        })
246    }
247}
248
249/// The deepest bracket nesting in a JSON text, outside its strings. Iterative, so a hostile
250/// body cannot exhaust the stack measuring it.
251pub fn json_depth(bytes: &[u8]) -> usize {
252    let (mut depth, mut max, mut in_string, mut escaped) = (0usize, 0usize, false, false);
253    for &b in bytes {
254        if in_string {
255            match (escaped, b) {
256                (true, _) => escaped = false,
257                (false, b'\\') => escaped = true,
258                (false, b'"') => in_string = false,
259                _ => {}
260            }
261            continue;
262        }
263        match b {
264            b'"' => in_string = true,
265            b'[' | b'{' => {
266                depth += 1;
267                max = max.max(depth);
268            }
269            b']' | b'}' => depth = depth.saturating_sub(1),
270            _ => {}
271        }
272    }
273    max
274}
275
276/// Parse a body, refusing one that nests deeper than [`MAX_JSON_DEPTH`] before parsing it.
277pub fn parse(body: &[u8]) -> Result<Value> {
278    let depth = json_depth(body);
279    ensure!(
280        depth <= MAX_JSON_DEPTH,
281        "the response nests {depth} levels deep; at most {MAX_JSON_DEPTH} are read"
282    );
283    serde_json::from_slice(body).map_err(|e| anyhow::anyhow!("the response is not JSON: {e}"))
284}
285
286/// A peer's text, made safe to show and bounded.
287pub fn text(s: &str, long: bool) -> String {
288    let clean = if long {
289        sanitize::multiline(s)
290    } else {
291        sanitize::line_field(s)
292    };
293    truncate_with_suffix(
294        &clean,
295        if long {
296            MAX_LONG_TEXT_BYTES
297        } else {
298            MAX_TEXT_BYTES
299        },
300        "...",
301    )
302}
303
304/// A scalar or a list of scalars, bounded; anything else is dropped (`None`).
305fn scalar(key: &str, v: &Value) -> Option<Value> {
306    match v {
307        Value::Null | Value::Bool(_) | Value::Number(_) => Some(v.clone()),
308        Value::String(s) => Some(Value::String(text(s, LONG_TEXT_FIELDS.contains(&key)))),
309        Value::Array(items) => Some(Value::Array(
310            items
311                .iter()
312                .filter(|x| !matches!(x, Value::Array(_) | Value::Object(_)))
313                .take(MAX_SCALAR_LIST)
314                .map(|x| match x {
315                    Value::String(s) => Value::String(text(s, false)),
316                    other => other.clone(),
317                })
318                .collect(),
319        )),
320        Value::Object(_) => None,
321    }
322}
323
324/// `fields` of `entry`, each a scalar or a list of scalars.
325pub fn project(entry: &Value, fields: &[&str]) -> Value {
326    let mut out = Map::new();
327    if let Some(obj) = entry.as_object() {
328        for key in fields {
329            if let Some(v) = obj.get(*key).and_then(|v| scalar(key, v)) {
330                out.insert((*key).to_string(), v);
331            }
332        }
333    }
334    Value::Object(out)
335}
336
337/// The entries of a wrapped list: `{outer: {inner: [...]}}`, `{outer: {inner: {...}}}` (a list
338/// of one), or `{outer: null}` / `{outer: {}}` (none). `single` names the envelope of a one-entry
339/// answer (`app`, `node`), which is also accepted.
340fn wrapped_list<'a>(
341    value: &'a Value,
342    outer: &str,
343    inner: &str,
344    single: Option<&str>,
345) -> Result<&'a [Value]> {
346    let obj = value
347        .as_object()
348        .ok_or_else(|| anyhow::anyhow!("the ResourceManager answers with a JSON object"))?;
349    if let Some(one) = single.and_then(|k| obj.get(k)) {
350        ensure!(
351            one.is_object(),
352            "{:?} is not an object",
353            single.unwrap_or("")
354        );
355        return Ok(std::slice::from_ref(one));
356    }
357    let wrapper = obj.get(outer).ok_or_else(|| {
358        anyhow::anyhow!(
359            "the answer carries no {outer:?}{}",
360            single.map(|s| format!(" or {s:?}")).unwrap_or_default()
361        )
362    })?;
363    let list = match wrapper {
364        Value::Null => return Ok(&[]),
365        Value::Object(w) => match w.get(inner) {
366            None | Some(Value::Null) => return Ok(&[]),
367            Some(list) => list,
368        },
369        _ => bail!("{outer:?} is neither an object nor null"),
370    };
371    match list {
372        Value::Array(items) => {
373            ensure!(
374                items.iter().all(Value::is_object),
375                "{outer}.{inner} holds something other than objects"
376            );
377            Ok(items.as_slice())
378        }
379        Value::Object(_) => Ok(std::slice::from_ref(list)),
380        _ => bail!("{outer}.{inner} is neither an array nor an object"),
381    }
382}
383
384/// The first `max_items` of `items`, each through `f`, with `count`/`total`/`truncated` set.
385fn take(
386    out: &mut Map<String, Value>,
387    key: &str,
388    items: &[Value],
389    max_items: usize,
390    f: impl Fn(&Value) -> Value,
391) {
392    let total = items.len();
393    let kept: Vec<Value> = items.iter().take(max_items).map(f).collect();
394    out.insert("count".into(), json!(kept.len()));
395    out.insert("total".into(), json!(total));
396    out.insert("truncated".into(), json!(total > max_items));
397    out.insert(key.into(), Value::Array(kept));
398}
399
400/// The children of one queue: the CapacityScheduler's `queues.queue`, the FairScheduler's
401/// `childQueues.queue` — an array, a single object, or nothing.
402fn child_queues(queue: &Value) -> Vec<&Value> {
403    let mut out = Vec::new();
404    for wrapper in ["queues", "childQueues"] {
405        match queue.get(wrapper).and_then(|w| w.get("queue")) {
406            Some(Value::Array(items)) => out.extend(items.iter().filter(|q| q.is_object())),
407            Some(one @ Value::Object(_)) => out.push(one),
408            _ => {}
409        }
410    }
411    out
412}
413
414/// Every queue under `root` (root itself excluded), depth-first in the server's order, walked
415/// with an explicit stack and stopped at [`MAX_QUEUES`]. Returns the projected queues and
416/// whether the walk stopped early.
417fn walk_queues(root: &Value) -> (Vec<Value>, bool) {
418    let mut out = Vec::new();
419    let mut stack: Vec<&Value> = child_queues(root).into_iter().rev().collect();
420    while let Some(queue) = stack.pop() {
421        if out.len() >= MAX_QUEUES {
422            return (out, true);
423        }
424        out.push(project(queue, QUEUE_FIELDS));
425        stack.extend(child_queues(queue).into_iter().rev());
426    }
427    (out, false)
428}
429
430/// The event payload for `resource`, on top of the validated request's `context`.
431pub fn decode(
432    resource: Resource,
433    operation: &str,
434    value: &Value,
435    max_items: usize,
436    context: &Map<String, Value>,
437) -> Result<Value> {
438    let mut out = context.clone();
439    out.insert("operation".into(), json!(operation));
440    match resource {
441        Resource::ClusterInfo => {
442            let info = value
443                .get("clusterInfo")
444                .filter(|v| v.is_object())
445                .ok_or_else(|| anyhow::anyhow!("the answer carries no \"clusterInfo\" object"))?;
446            ensure!(
447                info.get("resourceManagerVersion")
448                    .is_some_and(Value::is_string),
449                "clusterInfo carries no resourceManagerVersion"
450            );
451            out.insert("cluster_info".into(), project(info, CLUSTER_INFO_FIELDS));
452        }
453        Resource::Metrics => {
454            let metrics = value
455                .get("clusterMetrics")
456                .filter(|v| v.is_object())
457                .ok_or_else(|| {
458                    anyhow::anyhow!("the answer carries no \"clusterMetrics\" object")
459                })?;
460            out.insert("metrics".into(), project(metrics, METRICS_FIELDS));
461        }
462        Resource::Scheduler => {
463            let info = value
464                .get("scheduler")
465                .and_then(|s| s.get("schedulerInfo"))
466                .filter(|v| v.is_object())
467                .ok_or_else(|| {
468                    anyhow::anyhow!("the answer carries no \"scheduler.schedulerInfo\" object")
469                })?;
470            // The FairScheduler puts its tree under rootQueue; the others at the top.
471            let root = info
472                .get("rootQueue")
473                .filter(|r| r.is_object())
474                .unwrap_or(info);
475            let (queues, cut) = walk_queues(root);
476            let mut summary = project(info, SCHEDULER_FIELDS);
477            if let (Some(s), Some(r)) = (summary.as_object_mut(), root.as_object()) {
478                for key in ["queueName", "capacity", "usedCapacity", "maxCapacity"] {
479                    if let (false, Some(v)) = (s.contains_key(key), r.get(key)) {
480                        if let Some(v) = scalar(key, v) {
481                            s.insert(key.into(), v);
482                        }
483                    }
484                }
485            }
486            out.insert("scheduler".into(), summary);
487            take(&mut out, "queues", &queues, max_items, Value::clone);
488            if cut {
489                out.insert("truncated".into(), json!(true));
490            }
491        }
492        Resource::Applications => {
493            let items = wrapped_list(value, "apps", "app", Some("app"))?;
494            take(&mut out, "applications", items, max_items, |x| {
495                project(x, APP_FIELDS)
496            });
497        }
498        Resource::Attempts => {
499            let items = wrapped_list(value, "appAttempts", "appAttempt", None)?;
500            take(&mut out, "attempts", items, max_items, |x| {
501                project(x, ATTEMPT_FIELDS)
502            });
503        }
504        Resource::AppState => {
505            let state = value
506                .get("state")
507                .and_then(Value::as_str)
508                .ok_or_else(|| anyhow::anyhow!("the answer carries no \"state\""))?;
509            out.insert("state".into(), json!(text(state, false)));
510        }
511        Resource::AppQueue => {
512            let queue = value
513                .get("queue")
514                .and_then(Value::as_str)
515                .ok_or_else(|| anyhow::anyhow!("the answer carries no \"queue\""))?;
516            out.insert("queue".into(), json!(text(queue, false)));
517        }
518        Resource::Nodes => {
519            let items = wrapped_list(value, "nodes", "node", Some("node"))?;
520            take(&mut out, "nodes", items, max_items, |x| {
521                project(x, NODE_FIELDS)
522            });
523        }
524    }
525    Ok(Value::Object(out))
526}
527
528/// The ResourceManager's own words for an error: `RemoteException.exception` and `.message`
529/// from its JSON envelope, when the body is one.
530pub fn remote_exception(body: &[u8]) -> Option<(String, String)> {
531    if json_depth(body) > MAX_JSON_DEPTH {
532        return None;
533    }
534    let value: Value = serde_json::from_slice(body).ok()?;
535    let e = value.get("RemoteException")?;
536    let exception = e.get("exception").and_then(Value::as_str).unwrap_or("");
537    let message = e.get("message").and_then(Value::as_str).unwrap_or("");
538    Some((text(exception, false), text(message, false)))
539}