manta_server/service/
node_details.rs

1//! Per-xname `NodeDetails` aggregation built from the backend
2//! dispatcher.
3//!
4//! Replaces the direct `csm_rs::node::utils::get_node_details` call
5//! that previously lived in `service/cluster.rs` and `service/node.rs`.
6//! Going through the dispatcher's per-trait methods keeps the service
7//! layer backend-agnostic — both CSM and OCHAMI implement the
8//! underlying `CfsTrait` / `BootParametersTrait` / `ComponentTrait` /
9//! `GroupTrait` calls used here, so the function works on either
10//! site without a runtime branch.
11//!
12//! The flow is one round of five parallel fetches followed by an
13//! in-memory join keyed by xname, intentionally trading marginally
14//! more total bytes pulled (the CFS session list is unfiltered) for
15//! O(1) per-xname lookup and no N+1 per-node HSM call. The
16//! `csm_rs::node::utils::get_node_details` it replaces used a
17//! semaphore-bounded `JoinSet` to fan out one HSM-membership call per
18//! node; this version derives memberships from a single
19//! `get_groups(None)` instead.
20
21use std::collections::HashMap;
22
23use manta_backend_dispatcher::error::Error;
24use manta_backend_dispatcher::interfaces::{
25  bss::BootParametersTrait,
26  cfs::CfsTrait,
27  hsm::{component::ComponentTrait, group::GroupTrait},
28};
29use manta_shared::types::dto::NodeDetails;
30
31use crate::server::common::app_context::InfraContext;
32
33/// Fallback string used when a backend field is absent. Matches the
34/// historical csm-rs behavior so callers (CLI table renderer, status
35/// summary) don't need to special-case.
36const NOT_FOUND: &str = "Not found";
37
38/// Return one [`NodeDetails`] per xname in `xnames`.
39///
40/// Xnames that are present in `xnames` but missing from any one of
41/// the five backend responses still get a row; the affected fields
42/// are filled with `"Not found"` so the per-row position in the
43/// returned vector matches `xnames` after sorting.
44///
45/// The caller is expected to have already validated group access to
46/// every xname; this helper does no authorization of its own.
47///
48/// The five backend calls — CFS components (filtered to the requested
49/// xname set), BSS boot parameters, full HSM-component metadata,
50/// successful CFS sessions (for the image → CFS-config map), and the
51/// full group list (for membership labels) — are issued through
52/// [`tokio::try_join!`] so they overlap on the wire. The join is
53/// purely in-memory and the result is sorted by xname for stable
54/// rendering.
55///
56/// # Errors
57///
58/// [`Error::NetError`] / [`Error::CsmError`] propagated from any of
59/// the five concurrent backend calls; the first error short-circuits
60/// the join.
61pub async fn get_node_details(
62  infra: &InfraContext<'_>,
63  token: &str,
64  xnames: &[String],
65) -> Result<Vec<NodeDetails>, Error> {
66  // CFS components endpoint takes a comma-separated id filter; build
67  // it once. The other backends accept xname slices directly.
68  let xname_filter = xnames.join(",");
69
70  let (cfs_components, boot_params_vec, hsm_components, cfs_sessions, groups) =
71    tokio::try_join!(
72      infra
73        .backend
74        .get_cfs_components(token, None, Some(&xname_filter), None),
75      infra.backend.get_bootparameters(token, xnames),
76      infra.backend.get_node_metadata_available(token),
77      // Successful sessions only — we use them to resolve image id →
78      // CFS configuration that built the image.
79      infra.backend.get_sessions(
80        token,
81        None,
82        None,
83        None,
84        None,
85        None,
86        None,
87        None,
88        Some(true),
89        None
90      ),
91      infra.backend.get_groups(token, None),
92    )?;
93
94  // Build xname → comma-separated group label lookup once.
95  let mut xname_to_groups: HashMap<String, Vec<String>> = HashMap::new();
96  for group in &groups {
97    if let Some(member_ids) =
98      group.members.as_ref().and_then(|m| m.ids.as_ref())
99    {
100      for id in member_ids {
101        xname_to_groups
102          .entry(id.clone())
103          .or_default()
104          .push(group.label.clone());
105      }
106    }
107  }
108
109  // Index the per-xname lookups so the build loop below is O(N).
110  let cfs_by_id: HashMap<&str, &_> = cfs_components
111    .iter()
112    .filter_map(|c| c.id.as_deref().map(|id| (id, c)))
113    .collect();
114  let hsm_by_id: HashMap<&str, &_> = hsm_components
115    .iter()
116    .filter_map(|c| c.id.as_deref().map(|id| (id, c)))
117    .collect();
118  // Pre-index boot params by xname so the per-node lookup below is O(1).
119  // boot_params_vec was the only backend result not yet indexed here.
120  let boot_by_xname: HashMap<&str, &_> = boot_params_vec
121    .iter()
122    .flat_map(|bp| bp.hosts.iter().map(move |h| (h.as_str(), bp)))
123    .collect();
124
125  // Image id → CFS configuration name that produced it.
126  let image_to_cfs_config: HashMap<String, String> = cfs_sessions
127    .iter()
128    .filter_map(|session| {
129      let result_id = session.get_first_result_id()?;
130      let configuration_name = session.configuration.as_ref()?.name.as_ref()?;
131      Some((result_id, configuration_name.clone()))
132    })
133    .collect();
134
135  let mut out: Vec<NodeDetails> = xnames
136    .iter()
137    .map(|xname| {
138      let hsm_info = hsm_by_id.get(xname.as_str());
139      let nid = hsm_info
140        .and_then(|c| c.nid)
141        .map_or_else(|| NOT_FOUND.to_string(), |n| format!("nid{n:0>6}"));
142      let power_status = hsm_info
143        .and_then(|c| c.state.as_ref())
144        .map_or_else(|| NOT_FOUND.to_string(), |s| s.to_uppercase());
145
146      let cfs = cfs_by_id.get(xname.as_str());
147      let desired_configuration = cfs
148        .and_then(|c| c.desired_config.clone())
149        .unwrap_or_else(|| NOT_FOUND.to_string());
150      let configuration_status = cfs
151        .and_then(|c| c.configuration_status.clone())
152        .unwrap_or_else(|| NOT_FOUND.to_string());
153      let enabled = cfs
154        .and_then(|c| c.enabled)
155        .map_or_else(|| NOT_FOUND.to_string(), |b| b.to_string());
156      let error_count = cfs
157        .and_then(|c| c.error_count)
158        .map_or_else(|| NOT_FOUND.to_string(), |n| n.to_string());
159
160      let boot_params = boot_by_xname.get(xname.as_str()).copied();
161      let (boot_image_id, kernel_params) = boot_params.map_or_else(
162        || (NOT_FOUND.to_string(), NOT_FOUND.to_string()),
163        |bp| {
164          (
165            bp.try_get_boot_image_id()
166              .unwrap_or_else(|| NOT_FOUND.to_string()),
167            bp.params.clone(),
168          )
169        },
170      );
171
172      let boot_configuration = image_to_cfs_config
173        .get(&boot_image_id)
174        .cloned()
175        .unwrap_or_else(|| NOT_FOUND.to_string());
176
177      let hsm = xname_to_groups
178        .get(xname)
179        .map(|labels| labels.join(", "))
180        .unwrap_or_default();
181
182      NodeDetails {
183        xname: xname.clone(),
184        nid,
185        hsm,
186        power_status,
187        desired_configuration,
188        configuration_status,
189        enabled,
190        error_count,
191        boot_image_id,
192        boot_configuration,
193        kernel_params,
194      }
195    })
196    .collect();
197
198  out.sort_by(|a, b| a.xname.cmp(&b.xname));
199
200  Ok(out)
201}