manta_shared/types/
cluster_status.rs

1//! Pure helpers for summarizing node and cluster status.
2//!
3//! Both the CLI (table rendering) and the server (`service::hardware`,
4//! `service::hw_cluster`) call into these. Living in `manta-shared`
5//! keeps the CLI from importing `crate::service::*` for what are
6//! really data-only helpers.
7
8use std::collections::HashMap;
9
10use crate::types::dto::NodeDetails;
11use manta_backend_dispatcher::types::NodeSummary;
12
13/// Divisor to convert MiB to GiB.
14const MIB_PER_GIB: usize = 1024;
15
16/// Compute a summary status from a list of node details.
17///
18/// Priority order: FAILED > OFF > ON > STANDBY > UNCONFIGURED > OK.
19/// The first matching condition wins, regardless of how many nodes
20/// fall under lower-priority states.
21///
22/// # Examples
23///
24/// One failed node makes the whole cluster `"FAILED"`, even if every
25/// other node is on and configured:
26///
27/// ```
28/// use manta_shared::types::cluster_status::compute_summary_status;
29/// use manta_shared::types::dto::NodeDetails;
30///
31/// fn n(power: &str, config: &str) -> NodeDetails {
32///   NodeDetails {
33///     xname: String::new(), nid: String::new(), hsm: String::new(),
34///     power_status: power.into(),
35///     desired_configuration: String::new(),
36///     configuration_status: config.into(),
37///     enabled: String::new(), error_count: String::new(),
38///     boot_image_id: String::new(), boot_configuration: String::new(),
39///     kernel_params: String::new(),
40///   }
41/// }
42///
43/// assert_eq!(
44///   compute_summary_status(&[
45///     n("ON", "failed"),
46///     n("ON", "configured"),
47///   ]),
48///   "FAILED",
49/// );
50/// assert_eq!(
51///   compute_summary_status(&[n("ON", "configured"), n("OFF", "configured")]),
52///   "OFF",
53/// );
54/// assert_eq!(compute_summary_status(&[]), "OK");
55/// ```
56pub fn compute_summary_status(nodes: &[NodeDetails]) -> &'static str {
57  if nodes
58    .iter()
59    .any(|n| n.configuration_status.eq_ignore_ascii_case("failed"))
60  {
61    "FAILED"
62  } else if nodes
63    .iter()
64    .any(|n| n.power_status.eq_ignore_ascii_case("OFF"))
65  {
66    "OFF"
67  } else if nodes
68    .iter()
69    .any(|n| n.power_status.eq_ignore_ascii_case("on"))
70  {
71    "ON"
72  } else if nodes
73    .iter()
74    .any(|n| n.power_status.eq_ignore_ascii_case("standby"))
75  {
76    "STANDBY"
77  } else if nodes
78    .iter()
79    .any(|n| !n.configuration_status.eq_ignore_ascii_case("configured"))
80  {
81    "UNCONFIGURED"
82  } else {
83    "OK"
84  }
85}
86
87/// Aggregate hardware component counts across nodes (summary view).
88///
89/// Counts processors, accelerators, and HSN NICs by their `info`
90/// string (verbatim, including whitespace) and accumulates memory
91/// as **GiB** under a key like `"Memory (GiB)"` (raw MiB / 1024,
92/// non-numeric capacities counted as 0). Components whose `info`
93/// is `None` are skipped (memory is the exception — it still
94/// contributes a 0 entry).
95///
96/// Companion to [`get_cluster_hw_pattern`] — they look similar but
97/// the outputs are **not** interchangeable; see that function's doc
98/// for the differences.
99pub fn calculate_group_hw_component_summary(
100  node_summary_vec: &[NodeSummary],
101) -> HashMap<String, usize> {
102  let mut node_hw_component_summary: HashMap<String, usize> = HashMap::new();
103
104  for node_summary in node_summary_vec {
105    for artifact_summary in &node_summary.processors {
106      if let Some(info) = artifact_summary.info.as_ref() {
107        node_hw_component_summary
108          .entry(info.clone())
109          .and_modify(|qty| *qty += 1)
110          .or_insert(1);
111      }
112    }
113    for artifact_summary in &node_summary.node_accels {
114      if let Some(info) = artifact_summary.info.as_ref() {
115        node_hw_component_summary
116          .entry(info.clone())
117          .and_modify(|qty| *qty += 1)
118          .or_insert(1);
119      }
120    }
121    for artifact_summary in &node_summary.memory {
122      let memory_capacity = artifact_summary
123        .info
124        .as_deref()
125        .unwrap_or("ERROR NA")
126        .split(' ')
127        .collect::<Vec<_>>()
128        .first()
129        .copied()
130        .unwrap_or("0")
131        .parse::<usize>()
132        .unwrap_or(0);
133      node_hw_component_summary
134        .entry(artifact_summary.r#type.to_string() + " (GiB)")
135        .and_modify(|qty| *qty += memory_capacity / MIB_PER_GIB)
136        .or_insert(memory_capacity / MIB_PER_GIB);
137    }
138    for artifact_summary in &node_summary.node_hsn_nics {
139      if let Some(info) = artifact_summary.info.as_ref() {
140        node_hw_component_summary
141          .entry(info.clone())
142          .and_modify(|qty| *qty += 1)
143          .or_insert(1);
144      }
145    }
146  }
147
148  node_hw_component_summary
149}
150
151/// Compute a hardware pattern signature for cluster-matching.
152///
153/// Differs from [`calculate_group_hw_component_summary`] in three
154/// load-bearing ways — picking the wrong helper silently shifts the
155/// numbers consumers see:
156///
157/// - Processor / accelerator keys have **all whitespace stripped**
158///   (`"AMDEPYC7763"`, not `"AMD EPYC 7763"`), so two clusters with
159///   the same CPU model but different vendor-string formatting hash
160///   to the same pattern.
161/// - Memory is accumulated under the literal key `"memory"` as the
162///   **raw MiB total**, not divided by 1024 and not suffixed
163///   `(GiB)`.
164/// - HSN NICs are not counted.
165pub fn get_cluster_hw_pattern(
166  hsm_summary: Vec<NodeSummary>,
167) -> HashMap<String, usize> {
168  let mut hsm_node_hw_component_count_hashmap: HashMap<String, usize> =
169    HashMap::new();
170
171  for node_summary in hsm_summary {
172    for processor in node_summary.processors {
173      if let Some(info) = processor.info {
174        hsm_node_hw_component_count_hashmap
175          .entry(info.chars().filter(|c| !c.is_whitespace()).collect())
176          .and_modify(|qty| *qty += 1)
177          .or_insert(1);
178      }
179    }
180
181    for node_accel in node_summary.node_accels {
182      if let Some(info) = node_accel.info {
183        hsm_node_hw_component_count_hashmap
184          .entry(info.chars().filter(|c| !c.is_whitespace()).collect())
185          .and_modify(|qty| *qty += 1)
186          .or_insert(1);
187      }
188    }
189
190    for memory_dimm in node_summary.memory {
191      let memory_capacity = memory_dimm
192        .info
193        .unwrap_or_else(|| "0".to_string())
194        .split(' ')
195        .next()
196        .unwrap_or("0")
197        .to_string()
198        .parse::<usize>()
199        .unwrap_or(0);
200
201      hsm_node_hw_component_count_hashmap
202        .entry("memory".to_string())
203        .and_modify(|qty| *qty += memory_capacity)
204        .or_insert(memory_capacity);
205    }
206  }
207
208  hsm_node_hw_component_count_hashmap
209}
210
211#[cfg(test)]
212mod tests {
213  use super::*;
214  use manta_backend_dispatcher::types::{ArtifactSummary, ArtifactType};
215
216  // ---- fixtures ----
217
218  fn node(power: &str, config: &str) -> NodeDetails {
219    NodeDetails {
220      xname: String::new(),
221      nid: String::new(),
222      hsm: String::new(),
223      power_status: power.to_string(),
224      desired_configuration: String::new(),
225      configuration_status: config.to_string(),
226      enabled: String::new(),
227      error_count: String::new(),
228      boot_image_id: String::new(),
229      boot_configuration: String::new(),
230      kernel_params: String::new(),
231    }
232  }
233
234  fn artifact(kind: ArtifactType, info: Option<&str>) -> ArtifactSummary {
235    ArtifactSummary {
236      xname: String::new(),
237      r#type: kind,
238      info: info.map(String::from),
239    }
240  }
241
242  fn summary(
243    processors: Vec<ArtifactSummary>,
244    memory: Vec<ArtifactSummary>,
245    accels: Vec<ArtifactSummary>,
246    nics: Vec<ArtifactSummary>,
247  ) -> NodeSummary {
248    NodeSummary {
249      xname: String::new(),
250      r#type: String::new(),
251      processors,
252      memory,
253      node_accels: accels,
254      node_hsn_nics: nics,
255    }
256  }
257
258  // ---- compute_summary_status priority ladder ----
259  //
260  // Priority: FAILED > OFF > ON > STANDBY > UNCONFIGURED > OK
261  // Each test mixes a higher-priority node with lower-priority ones
262  // to pin the precedence — a swap (e.g. OFF and ON reversed) would
263  // change what operators see in `manta get cluster` and is silent
264  // without these tests.
265
266  #[test]
267  fn summary_status_failed_beats_everything() {
268    let nodes = [
269      node("ON", "failed"),
270      node("OFF", "configured"),
271      node("on", "configured"),
272    ];
273    assert_eq!(compute_summary_status(&nodes), "FAILED");
274  }
275
276  #[test]
277  fn summary_status_off_beats_on() {
278    let nodes = [node("OFF", "configured"), node("on", "configured")];
279    assert_eq!(compute_summary_status(&nodes), "OFF");
280  }
281
282  #[test]
283  fn summary_status_on_beats_standby() {
284    let nodes = [node("on", "configured"), node("standby", "configured")];
285    assert_eq!(compute_summary_status(&nodes), "ON");
286  }
287
288  #[test]
289  fn summary_status_standby_beats_unconfigured() {
290    let nodes = [node("standby", "configured"), node("ready", "pending")];
291    assert_eq!(compute_summary_status(&nodes), "STANDBY");
292  }
293
294  #[test]
295  fn summary_status_unconfigured_when_only_config_differs() {
296    let nodes = [node("ready", "pending")];
297    assert_eq!(compute_summary_status(&nodes), "UNCONFIGURED");
298  }
299
300  #[test]
301  fn summary_status_ok_when_all_configured_and_no_known_power_state() {
302    let nodes = [node("ready", "configured"), node("ready", "configured")];
303    assert_eq!(compute_summary_status(&nodes), "OK");
304  }
305
306  #[test]
307  fn summary_status_empty_input_is_ok() {
308    // No nodes means no `any()` matches, falls through to OK.
309    // Worth pinning so callers can rely on it instead of pre-checking.
310    assert_eq!(compute_summary_status(&[]), "OK");
311  }
312
313  #[test]
314  fn summary_status_matches_case_insensitively() {
315    // Power and configuration status checks use eq_ignore_ascii_case.
316    assert_eq!(compute_summary_status(&[node("off", "configured")]), "OFF");
317    assert_eq!(compute_summary_status(&[node("ON", "CONFIGURED")]), "ON");
318  }
319
320  // ---- calculate_group_hw_component_summary ----
321
322  #[test]
323  fn hw_summary_empty_input_is_empty() {
324    assert!(calculate_group_hw_component_summary(&[]).is_empty());
325  }
326
327  #[test]
328  fn hw_summary_counts_identical_processors_across_nodes() {
329    let node_a = summary(
330      vec![
331        artifact(ArtifactType::Processor, Some("AMD EPYC 7763")),
332        artifact(ArtifactType::Processor, Some("AMD EPYC 7763")),
333      ],
334      vec![],
335      vec![],
336      vec![],
337    );
338    let node_b = summary(
339      vec![artifact(ArtifactType::Processor, Some("AMD EPYC 7763"))],
340      vec![],
341      vec![],
342      vec![],
343    );
344    let got = calculate_group_hw_component_summary(&[node_a, node_b]);
345    assert_eq!(got.get("AMD EPYC 7763"), Some(&3));
346  }
347
348  #[test]
349  fn hw_summary_converts_memory_mib_to_gib() {
350    // 524 288 MiB / 1024 = 512 GiB.
351    let node = summary(
352      vec![],
353      vec![artifact(ArtifactType::Memory, Some("524288 MiB"))],
354      vec![],
355      vec![],
356    );
357    let got = calculate_group_hw_component_summary(&[node]);
358    assert_eq!(got.get("Memory (GiB)"), Some(&512));
359  }
360
361  #[test]
362  fn hw_summary_skips_artifacts_with_no_info_field() {
363    // Processors / accels / NICs with `info = None` must not be counted.
364    let node = summary(
365      vec![artifact(ArtifactType::Processor, None)],
366      vec![],
367      vec![artifact(ArtifactType::NodeAccel, None)],
368      vec![artifact(ArtifactType::NodeHsnNic, None)],
369    );
370    assert!(calculate_group_hw_component_summary(&[node]).is_empty());
371  }
372
373  #[test]
374  fn hw_summary_treats_unparseable_memory_as_zero() {
375    // "ERROR NA".parse::<usize>() fails — the function defaults to 0,
376    // which still creates the entry with value 0. Pin the behaviour
377    // so a future "raise on parse error" change is deliberate.
378    let node = summary(
379      vec![],
380      vec![artifact(ArtifactType::Memory, Some("garbage"))],
381      vec![],
382      vec![],
383    );
384    let got = calculate_group_hw_component_summary(&[node]);
385    assert_eq!(got.get("Memory (GiB)"), Some(&0));
386  }
387
388  // ---- get_cluster_hw_pattern ----
389
390  #[test]
391  fn hw_pattern_empty_input_is_empty() {
392    assert!(get_cluster_hw_pattern(vec![]).is_empty());
393  }
394
395  #[test]
396  fn hw_pattern_strips_whitespace_from_processor_info() {
397    let node = summary(
398      vec![artifact(ArtifactType::Processor, Some("AMD EPYC 7763"))],
399      vec![],
400      vec![],
401      vec![],
402    );
403    let got = get_cluster_hw_pattern(vec![node]);
404    assert_eq!(got.get("AMDEPYC7763"), Some(&1));
405    assert!(
406      !got.contains_key("AMD EPYC 7763"),
407      "whitespace-bearing key must NOT be present"
408    );
409  }
410
411  #[test]
412  fn hw_pattern_aggregates_memory_as_raw_value_not_gib() {
413    // Unlike `calculate_group_hw_component_summary`, this helper does
414    // NOT divide memory by 1024; it sums the raw value under the
415    // literal key "memory". Catches a future "let's unify these
416    // helpers" change that would silently shift consumers' numbers.
417    let node = summary(
418      vec![],
419      vec![artifact(ArtifactType::Memory, Some("512 MiB"))],
420      vec![],
421      vec![],
422    );
423    let got = get_cluster_hw_pattern(vec![node]);
424    assert_eq!(got.get("memory"), Some(&512));
425  }
426}