manta_server/service/
node_ops.rs

1//! Node-expression resolution: parsing hostlist strings, NID-to-xname
2//! translation, HSM-group expansion, and the authorization helpers
3//! that validate the caller can act on the resolved set.
4
5use std::collections::HashMap;
6use std::sync::LazyLock;
7
8use hostlist_parser::parse;
9use manta_backend_dispatcher::{
10  error::Error,
11  interfaces::hsm::{component::ComponentTrait, group::GroupTrait},
12  types::Component,
13};
14use regex::Regex;
15
16// Compile-time constant pattern — .expect() is safe here because
17// the regex literal is known to be valid and will never fail.
18static XNAME_RE: LazyLock<Regex> = LazyLock::new(|| {
19  Regex::new(r"^x\d{4}c[0-7]s([0-9]|[1-5][0-9]|6[0-4])b[0-1]n[0-7]$")
20    .expect("Invalid xname regex pattern")
21});
22
23use crate::server::common::app_context::InfraContext;
24
25/// Length of a NID string, e.g. "nid000001" = 9 characters.
26const NID_STRING_LENGTH: usize = 9;
27
28/// Length of the xname blade prefix, e.g. "x1000c7s0b" = 10 characters.
29const XNAME_BLADE_PREFIX_LEN: usize = 10;
30
31// Validate and get short nid
32fn get_short_nid(long_nid: &str) -> Result<usize, Error> {
33  if long_nid.len() != NID_STRING_LENGTH {
34    return Err(Error::InvalidNodeId(format!(
35      "Nid '{long_nid}' not valid, Nid does not have {NID_STRING_LENGTH} characters"
36    )));
37  }
38
39  let nid_number = long_nid.strip_prefix("nid").ok_or_else(|| {
40    Error::InvalidNodeId(format!(
41      "Nid '{long_nid}' not valid, 'nid' prefix missing"
42    ))
43  })?;
44
45  nid_number.parse::<usize>().map_err(|e| {
46    Error::InvalidNodeId(format!(
47      "Could not convert Nid '{nid_number}' from long to short format: {e}"
48    ))
49  })
50}
51
52/// Resolve a NID hostlist expression to xnames by
53/// cross-referencing available node metadata.
54pub fn get_xname_from_nid_hostlist(
55  node_vec: &[String],
56  node_metadata_available_vec: &[Component],
57) -> Result<Vec<String>, Error> {
58  // Convert long nids to short nids
59  // Get xnames from short nids
60  let short_nid_vec: Vec<usize> = node_vec
61    .iter()
62    .map(|nid_long| get_short_nid(nid_long))
63    .collect::<Result<Vec<_>, _>>()?;
64
65  tracing::debug!("short Nid list expanded: {:?}", short_nid_vec);
66
67  // Build a HashSet once so the per-component lookup below is O(1).
68  // The previous `short_nid_vec.contains(&nid)` was O(N) — at cluster
69  // scale (say a hostlist `nid[1-5000]` against ~5k components) that
70  // turned into a 25M-comparison filter on every resolve.
71  let short_nid_set: std::collections::HashSet<usize> =
72    short_nid_vec.iter().copied().collect();
73  let xname_vec: Vec<String> = node_metadata_available_vec
74    .iter()
75    .filter(|node_metadata_available| {
76      node_metadata_available
77        .nid
78        .is_some_and(|nid| short_nid_set.contains(&nid))
79    })
80    .filter_map(|node_metadata_available| {
81      node_metadata_available.id.as_ref().cloned()
82    })
83    .collect();
84
85  Ok(xname_vec)
86}
87
88/// Filter available node metadata to only those xnames
89/// present in `node_vec`.
90pub fn get_xname_from_xname_hostlist(
91  node_vec: &[String],
92  node_metadata_available_vec: &[Component],
93) -> Result<Vec<String>, Error> {
94  // If hostlist of XNAMEs, return hostlist expanded xnames
95  // Validate XNAMEs.
96  //
97  // Hash the requested-xname list once — same reasoning as
98  // `get_xname_from_nid_hostlist`: at cluster scale the
99  // `node_vec.contains(id)` filter was O(N·M).
100  let node_set: std::collections::HashSet<&str> =
101    node_vec.iter().map(String::as_str).collect();
102  let xname_vec: Vec<String> = node_metadata_available_vec
103    .iter()
104    .filter(|node_metadata_available| {
105      node_metadata_available
106        .id
107        .as_ref()
108        .is_some_and(|id| node_set.contains(id.as_str()))
109    })
110    .filter_map(|node_metadata_available| {
111      node_metadata_available.id.as_ref().cloned()
112    })
113    .collect();
114
115  Ok(xname_vec)
116}
117
118/// Convenience wrapper that fetches node metadata from the backend
119/// and resolves a hosts expression to a sorted, deduplicated list
120/// of xnames.
121///
122/// Combines the two-step pattern of
123/// [`ComponentTrait::get_node_metadata_available`] (called on the
124/// backend held in [`InfraContext`]) followed by
125/// [`from_hosts_expression_to_xname_vec`] that recurs in many
126/// command files.
127pub async fn from_user_hosts_expression_to_xname_vec(
128  infra: &InfraContext<'_>,
129  shasta_token: &str,
130  hosts_expression: &str,
131  is_include_siblings: bool,
132) -> Result<Vec<String>, Error> {
133  let node_metadata_available_vec = infra
134    .backend
135    .get_node_metadata_available(shasta_token)
136    .await?;
137
138  let mut xname_vec = from_hosts_expression_to_xname_vec(
139    hosts_expression,
140    is_include_siblings,
141    &node_metadata_available_vec,
142  )?;
143
144  xname_vec.sort();
145  xname_vec.dedup();
146
147  Ok(xname_vec)
148}
149
150/// Translates a 'host expression' into a list of xnames.
151///
152/// A host expression is a comma-separated list of NIDs or xnames, a regex,
153/// or a hostlist. When `is_include_siblings` is true, the resulting xnames
154/// are expanded to include all siblings (other nodes on the same BMC).
155pub fn from_hosts_expression_to_xname_vec(
156  user_input: &str,
157  is_include_siblings: bool,
158  node_metadata_available_vec: &[Component],
159) -> Result<Vec<String>, Error> {
160  let hostlist_expanded_vec_rslt =
161    parse(user_input).map_err(|e| Error::InvalidNodeId(e.to_string()));
162
163  let xname_vec = match hostlist_expanded_vec_rslt {
164    Ok(node_vec) => {
165      tracing::debug!("Hostlist format is valid");
166      let xname_vec: Vec<String> = if validate_nid_format_vec(&node_vec) {
167        tracing::debug!("NID format is valid");
168        tracing::debug!("hostlist Nids: {}", user_input);
169        tracing::debug!("hostlist Nids expanded: {:?}", node_vec);
170
171        get_xname_from_nid_hostlist(&node_vec, node_metadata_available_vec)?
172      } else if validate_xname_format_vec(&node_vec) {
173        tracing::debug!("XNAME format is valid");
174        tracing::debug!("hostlist XNAMEs: {}", user_input);
175        tracing::debug!("hostlist XNAMEs expanded: {:?}", node_vec);
176
177        get_xname_from_xname_hostlist(&node_vec, node_metadata_available_vec)?
178      } else {
179        return Err(Error::BadRequest(
180          "Could not parse user input as a list of nodes from a hostlist expression."
181            .to_string(),
182        ));
183      };
184
185      xname_vec
186    }
187    Err(e) => {
188      return Err(Error::BadRequest(format!(
189        "Could not parse user input as a list of nodes from a hostlist or regex expression: {e}"
190      )));
191    }
192  };
193
194  if xname_vec.is_empty() {
195    return Err(Error::BadRequest(
196      "Could not parse user input as a list of nodes from a hostlist or regex expression."
197        .to_string(),
198    ));
199  }
200
201  // Include siblings if requested
202  let xname_vec: Vec<String> = if is_include_siblings {
203    tracing::debug!("Include siblings");
204    let xname_blade_vec: Vec<String> = xname_vec
205      .iter()
206      .map(|xname| {
207        xname
208          .get(0..XNAME_BLADE_PREFIX_LEN)
209          .unwrap_or(xname)
210          .to_string()
211      })
212      .collect();
213
214    tracing::debug!("XNAME blades:\n{:?}", xname_blade_vec);
215
216    // Include siblings: keep any node whose xname shares a blade
217    // prefix with one of the resolved xnames.
218    node_metadata_available_vec
219      .iter()
220      .filter(|node_metadata_available| {
221        node_metadata_available.id.as_ref().is_some_and(|id| {
222          xname_blade_vec
223            .iter()
224            .any(|xname_blade| id.starts_with(xname_blade))
225        })
226      })
227      .filter_map(|node_metadata_available| node_metadata_available.id.as_ref())
228      .cloned()
229      .collect()
230  } else {
231    xname_vec
232  };
233
234  Ok(xname_vec)
235}
236
237/// Group the supplied xnames by their parent HSM group.
238///
239/// Fetches the HSM groups the caller can access, then for each group
240/// returns the intersection of its membership with `xname_vec`.
241/// Groups whose intersection is empty are omitted, so the returned
242/// map contains only groups that actually contribute at least one
243/// matching node.
244pub async fn get_curated_group_from_xname_hostlist(
245  infra: &InfraContext<'_>,
246  auth_token: &str,
247  xname_vec: &[String],
248) -> Result<HashMap<String, Vec<String>>, Error> {
249  let mut hsm_group_summary: HashMap<String, Vec<String>> = HashMap::new();
250
251  let hsm_name_available_vec =
252    infra.backend.get_group_name_available(auth_token).await?;
253
254  let names_ref: Vec<&str> =
255    hsm_name_available_vec.iter().map(String::as_str).collect();
256  let hsm_group_available_map = infra
257    .backend
258    .get_group_map_and_filter_by_group_vec(auth_token, &names_ref)
259    .await?;
260
261  // Filter hsm group members. Pre-compute a hash of the requested
262  // xname set once — the outer loop is over groups and the inner
263  // `xname_vec.contains(xname)` would otherwise re-scan the full
264  // requested list per member per group (groups × members × xnames).
265  let xname_set: std::collections::HashSet<&str> =
266    xname_vec.iter().map(String::as_str).collect();
267  for (hsm_name, hsm_members) in hsm_group_available_map {
268    let xname_filtered: Vec<String> = hsm_members
269      .iter()
270      .filter(|xname| xname_set.contains(xname.as_str()))
271      .cloned()
272      .collect();
273    if !xname_filtered.is_empty() {
274      hsm_group_summary.insert(hsm_name, xname_filtered);
275    }
276  }
277
278  Ok(hsm_group_summary)
279}
280
281fn validate_nid_format_vec(node_vec: &[String]) -> bool {
282  node_vec.iter().all(|nid| validate_nid_format(nid))
283}
284
285fn validate_nid_format(nid: &str) -> bool {
286  nid.to_lowercase().starts_with("nid")
287    && nid.len() == 9
288    && nid
289      .strip_prefix("nid")
290      .is_some_and(|nid_number| nid_number.chars().all(char::is_numeric))
291}
292
293fn validate_xname_format_vec(node_vec: &[String]) -> bool {
294  node_vec.iter().all(|nid| validate_xname_format(nid))
295}
296
297/// Return `true` if `xname` matches the HPE Cray xname regex.
298pub(crate) fn validate_xname_format(xname: &str) -> bool {
299  XNAME_RE.is_match(xname)
300}
301
302/// Resolve target nodes from either a hosts expression, an
303/// explicit HSM group name, or the settings-level HSM group.
304///
305/// Priority order:
306/// 1. `hosts_expression` — parsed and validated via
307///    [`from_user_hosts_expression_to_xname_vec`].
308/// 2. `group_name_arg_opt` — the group name supplied by the CLI's
309///    `--group` flag (also accepted as `--hsm-group`); validated for
310///    access via
311///    [`crate::service::authorization::validate_user_group_access`],
312///    then expanded to member xnames.
313/// 3. `settings_group_name_opt` — the group configured in
314///    `cli.toml`'s `hsm_group`; same treatment as (2).
315///
316/// Returns a sorted, deduplicated `Vec<String>` of xnames.
317pub async fn resolve_target_nodes(
318  infra: &InfraContext<'_>,
319  token: &str,
320  hosts_expression_opt: Option<&str>,
321  group_name_arg_opt: Option<&str>,
322  settings_group_name_opt: Option<&str>,
323) -> Result<Vec<String>, Error> {
324  if let Some(hosts_expr) = hosts_expression_opt {
325    from_user_hosts_expression_to_xname_vec(infra, token, hosts_expr, false)
326      .await
327  } else if let Some(target_group) =
328    group_name_arg_opt.or(settings_group_name_opt)
329  {
330    crate::service::authorization::validate_user_group_access(
331      infra,
332      token,
333      target_group,
334    )
335    .await?;
336
337    infra
338      .backend
339      .get_member_vec_from_group_name_vec(token, &[target_group.to_string()])
340      .await
341  } else {
342    Err(Error::BadRequest(
343      "No nodes provided. Please provide either a list of nodes \
344       via --nodes or an HSM group via --hsm-group"
345        .to_string(),
346    ))
347  }
348}
349
350#[cfg(test)]
351mod tests;