manta_server/service/
hardware.rs

1//! Hardware inventory queries for individual nodes and clusters.
2//!
3//! Both query shapes (cluster-by-group, nodes-by-expression) fan out
4//! one per-xname `get_inventory_hardware_query` call concurrently,
5//! rate-limited by a Tokio semaphore at the
6//! `HW_INVENTORY_CONCURRENCY_LIMIT` constant defined below. Failed
7//! per-node fetches are logged and replaced by an empty [`NodeSummary`]
8//! so the response vector lines up with the input xname list.
9//!
10//! The internal aggregation helper
11//! `calculate_group_hw_component_summary` lives in
12//! `manta_shared::types::cluster_status`; it's only re-imported here
13//! to back the test module.
14
15use std::sync::Arc;
16use std::time::Instant;
17
18use manta_backend_dispatcher::error::Error;
19use manta_backend_dispatcher::interfaces::hsm::group::GroupTrait;
20use manta_backend_dispatcher::interfaces::hsm::{
21  component::ComponentTrait, hardware_inventory::HardwareInventory,
22};
23use manta_backend_dispatcher::types::NodeSummary;
24use tokio::sync::Semaphore;
25
26use crate::server::common::app_context::InfraContext;
27use crate::service::authorization::validate_user_group_members_access;
28use crate::service::node_ops::from_hosts_expression_to_xname_vec;
29pub use manta_shared::types::api::hardware::{
30  GetHardwareClusterParams, GetHardwareNodesListParams,
31};
32
33/// Maximum number of concurrent hardware inventory requests.
34const HW_INVENTORY_CONCURRENCY_LIMIT: usize = 15;
35
36// ── Hardware Cluster ──
37
38/// Result of a hardware cluster query.
39pub struct HardwareClusterResult {
40  /// Resolved HSM group name the inventory was collected for (may
41  /// differ from the requested name if the caller's authorization
42  /// only permitted a subset).
43  pub hsm_group_name: String,
44  /// Per-node hardware summaries, one entry per group member.
45  pub node_summaries: Vec<NodeSummary>,
46}
47
48/// Fetch hardware inventory for a slice of xnames concurrently,
49/// rate-limited by a semaphore. Shared by cluster and nodes-list queries.
50async fn fetch_node_summaries(
51  infra: &InfraContext<'_>,
52  token: &str,
53  xnames: &[String],
54) -> Vec<NodeSummary> {
55  let mut tasks = tokio::task::JoinSet::new();
56  let sem = Arc::new(Semaphore::new(HW_INVENTORY_CONCURRENCY_LIMIT));
57
58  let n = xnames.len();
59  let width = n.checked_ilog10().unwrap_or(0) as usize + 1;
60
61  for (i, xname) in xnames.iter().enumerate() {
62    tracing::info!(
63      "\rGetting hw components for node '{xname}' [{:>width$}/{n}]",
64      i + 1
65    );
66
67    let backend_cp = infra.backend_clone();
68    let token_str = token.to_string();
69    let xname_str = xname.clone();
70    let permit = Arc::clone(&sem).acquire_owned().await;
71
72    tasks.spawn(async move {
73      let _permit = permit;
74      let hw_inventory_typed = backend_cp
75        .get_inventory_hardware_query(
76          &token_str, &xname_str, None, None, None, None, None,
77        )
78        .await;
79
80      // `NodeSummary::from_csm_value` still parses out of a JSON Value;
81      // re-serialize the typed `HWInventory` and pluck `/Nodes/0` like
82      // before. A future cleanup can replace this round-trip with a
83      // typed constructor that takes `&HWInventory` directly.
84      let node_hw_opt = match hw_inventory_typed {
85        Ok(hw_inv) => serde_json::to_value(&hw_inv)
86          .ok()
87          .and_then(|v| v.pointer("/Nodes/0").cloned()),
88        Err(e) => {
89          tracing::error!(
90            "Failed to get HW inventory for '{}': {}",
91            xname_str,
92            e
93          );
94          None
95        }
96      };
97
98      match node_hw_opt {
99        Some(v) => NodeSummary::from_csm_value(v),
100        None => NodeSummary {
101          xname: xname_str,
102          ..Default::default()
103        },
104      }
105    });
106  }
107
108  let mut summaries = Vec::with_capacity(n);
109  while let Some(res) = tasks.join_next().await {
110    match res {
111      Ok(s) => summaries.push(s),
112      Err(e) => {
113        tracing::error!("Failed fetching node hardware information: {}", e);
114      }
115    }
116  }
117  summaries
118}
119
120/// Fetch hardware inventory for every member of an HSM group.
121///
122/// When `params.group_name` is unset, the first group the caller has
123/// access to is used and surfaced back through
124/// `HardwareClusterResult::hsm_group_name`. Per-node inventory
125/// queries run concurrently, capped by `HW_INVENTORY_CONCURRENCY_LIMIT`.
126/// Empty groups are logged but not treated as an error.
127///
128/// # Errors
129///
130/// - [`Error::BadRequest`] when `params.group_name` is unreachable
131///   for the caller.
132/// - [`Error::NotFound`] when the caller has no accessible groups
133///   and no `params.group_name` was supplied.
134/// - [`Error::NetError`] / [`Error::CsmError`] from
135///   `get_group_available` / `get_group`. Per-node inventory failures
136///   degrade to an empty `NodeSummary` row rather than surfacing an
137///   error.
138pub async fn get_hardware_cluster(
139  infra: &InfraContext<'_>,
140  token: &str,
141  params: &GetHardwareClusterParams,
142) -> Result<HardwareClusterResult, Error> {
143  // One `get_group_available` call plus in-memory access validation
144  // replaces the prior two round-trips:
145  //   1. `get_group_available` to derive labels, then
146  //   2. `validate_user_group_vec_access` which internally called
147  //      `get_group_name_available` again for non-admin callers.
148  // The subsequent `backend.get_group()` call below is untouched —
149  // it fetches member lists for the chosen group, which is separate.
150  let (_, target_group_vec) =
151    crate::service::group::resolve_target_and_available_groups(
152      infra,
153      token,
154      params.group_name.as_deref(),
155    )
156    .await?;
157
158  let hsm_group_name = target_group_vec
159    .first()
160    .ok_or_else(|| {
161      Error::NotFound("No HSM groups available for this user".to_string())
162    })?
163    .clone();
164
165  let hsm_group = infra.backend.get_group(token, &hsm_group_name).await?;
166
167  let members = hsm_group
168    .members
169    .unwrap_or_default()
170    .ids
171    .unwrap_or_default();
172
173  if members.is_empty() {
174    tracing::warn!("HSM group '{}' has no members", hsm_group.label);
175  }
176
177  tracing::debug!(
178    "Get HW artifacts for nodes in HSM group '{}' and members {:?}",
179    hsm_group.label,
180    members
181  );
182
183  let start_total = Instant::now();
184  let node_summaries = fetch_node_summaries(infra, token, &members).await;
185  tracing::info!(
186    "Time elapsed getting hw inventory for HSM '{}': {:?}",
187    hsm_group_name,
188    start_total.elapsed()
189  );
190
191  Ok(HardwareClusterResult {
192    hsm_group_name,
193    node_summaries,
194  })
195}
196
197// ── Hardware Nodes List ──
198
199/// Result of a hardware nodes-list query.
200pub struct HardwareNodesListResult {
201  /// Per-node hardware summaries, one entry per resolved xname.
202  pub node_summaries: Vec<NodeSummary>,
203}
204
205/// Fetch hardware inventory for the nodes named by
206/// `params.host_expression`.
207///
208/// The expression is parsed by [`from_hosts_expression_to_xname_vec`]
209/// (hostlist notation, NIDs, or xnames; siblings are not expanded
210/// here). An empty resolution yields `BadRequest` rather than a
211/// silent no-op. The caller's group access to every resolved xname is
212/// validated through [`validate_user_group_members_access`] before
213/// the per-node inventory fan-out runs.
214///
215/// # Errors
216///
217/// - [`Error::InvalidNodeId`] / [`Error::BadRequest`] when the
218///   expression cannot be parsed or resolves to an empty xname set.
219/// - [`Error::BadRequest`] when the caller lacks group access to one
220///   of the resolved xnames.
221/// - [`Error::NetError`] / [`Error::CsmError`] from
222///   `get_node_metadata_available`. Per-node inventory failures
223///   degrade to an empty `NodeSummary` row rather than surfacing an
224///   error.
225pub async fn get_hardware_nodes_list(
226  infra: &InfraContext<'_>,
227  token: &str,
228  params: &GetHardwareNodesListParams,
229) -> Result<HardwareNodesListResult, Error> {
230  let node_metadata_available_vec =
231    infra.backend.get_node_metadata_available(token).await?;
232
233  let node_list = from_hosts_expression_to_xname_vec(
234    &params.host_expression,
235    false,
236    &node_metadata_available_vec,
237  )?;
238
239  if node_list.is_empty() {
240    return Err(Error::BadRequest(
241      "The list of nodes to operate is empty. Nothing to do".to_string(),
242    ));
243  }
244
245  // Validate xnames
246  validate_user_group_members_access(infra, token, &node_list).await?;
247
248  let node_summaries = fetch_node_summaries(infra, token, &node_list).await;
249  Ok(HardwareNodesListResult { node_summaries })
250}
251
252// `calculate_group_hw_component_summary` and `get_cluster_hw_pattern` moved
253// to `manta_shared::types::cluster_status`. Only
254// `calculate_group_hw_component_summary` is still needed locally — the
255// tests below use it.
256#[cfg(test)]
257use manta_shared::types::cluster_status::calculate_group_hw_component_summary;
258
259#[cfg(test)]
260mod tests {
261  use super::*;
262  use manta_backend_dispatcher::types::{
263    ArtifactSummary, ArtifactType, NodeSummary,
264  };
265
266  /// Helper: create an ArtifactSummary with the given info string.
267  fn make_artifact(
268    art_type: ArtifactType,
269    info: Option<&str>,
270  ) -> ArtifactSummary {
271    ArtifactSummary {
272      xname: "x0".to_string(),
273      r#type: art_type,
274      info: info.map(String::from),
275    }
276  }
277
278  #[test]
279  fn summary_counts_processors_and_accels() {
280    let nodes = vec![NodeSummary {
281      xname: "x1000c0s0b0n0".to_string(),
282      processors: vec![
283        make_artifact(ArtifactType::Processor, Some("AMD EPYC 7742")),
284        make_artifact(ArtifactType::Processor, Some("AMD EPYC 7742")),
285      ],
286      node_accels: vec![make_artifact(
287        ArtifactType::NodeAccel,
288        Some("NVIDIA A100"),
289      )],
290      memory: vec![],
291      node_hsn_nics: vec![],
292      ..Default::default()
293    }];
294    let summary = calculate_group_hw_component_summary(&nodes);
295    assert_eq!(summary.get("AMD EPYC 7742"), Some(&2));
296    assert_eq!(summary.get("NVIDIA A100"), Some(&1));
297  }
298
299  #[test]
300  fn summary_converts_memory_mib_to_gib() {
301    let nodes = vec![NodeSummary {
302      xname: "x1000c0s0b0n0".to_string(),
303      processors: vec![],
304      node_accels: vec![],
305      memory: vec![
306        ArtifactSummary {
307          xname: "x0".to_string(),
308          r#type: ArtifactType::Memory,
309          info: Some("16384 MiB".to_string()),
310        },
311        ArtifactSummary {
312          xname: "x0".to_string(),
313          r#type: ArtifactType::Memory,
314          info: Some("16384 MiB".to_string()),
315        },
316      ],
317      node_hsn_nics: vec![],
318      ..Default::default()
319    }];
320    let summary = calculate_group_hw_component_summary(&nodes);
321    assert_eq!(summary.get("Memory (GiB)"), Some(&32));
322  }
323
324  #[test]
325  fn summary_aggregates_across_multiple_nodes() {
326    let nodes = vec![
327      NodeSummary {
328        xname: "n1".to_string(),
329        processors: vec![make_artifact(
330          ArtifactType::Processor,
331          Some("AMD EPYC 7742"),
332        )],
333        ..Default::default()
334      },
335      NodeSummary {
336        xname: "n2".to_string(),
337        processors: vec![
338          make_artifact(ArtifactType::Processor, Some("AMD EPYC 7742")),
339          make_artifact(ArtifactType::Processor, Some("Intel Xeon Gold")),
340        ],
341        ..Default::default()
342      },
343    ];
344    let summary = calculate_group_hw_component_summary(&nodes);
345    assert_eq!(summary.get("AMD EPYC 7742"), Some(&2));
346    assert_eq!(summary.get("Intel Xeon Gold"), Some(&1));
347  }
348
349  #[test]
350  fn summary_empty_nodes() {
351    let nodes: Vec<NodeSummary> = vec![];
352    let summary = calculate_group_hw_component_summary(&nodes);
353    assert!(summary.is_empty());
354  }
355
356  #[test]
357  fn summary_skips_none_info_in_processors() {
358    let nodes = vec![NodeSummary {
359      xname: "n1".to_string(),
360      processors: vec![
361        make_artifact(ArtifactType::Processor, None),
362        make_artifact(ArtifactType::Processor, Some("AMD EPYC 7742")),
363      ],
364      ..Default::default()
365    }];
366    let summary = calculate_group_hw_component_summary(&nodes);
367    assert_eq!(summary.get("AMD EPYC 7742"), Some(&1));
368    assert_eq!(summary.len(), 1);
369  }
370}