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//!
5//! The functions here form the "front of the funnel" for any command
6//! that takes `--xnames`, `--nids`, or `--hsm-group`. The two entry
7//! points are:
8//!
9//! - [`from_user_hosts_expression_to_xname_vec`] — `(infra, token,
10//!   expression, include_siblings)` → sorted, deduplicated xname vec.
11//!   Used by every command whose input is a free-form `--xnames`
12//!   string.
13//! - [`resolve_target_nodes`] — `(infra, token, hosts_expression,
14//!   group_name, settings_group_name)` → xname vec via a 3-way
15//!   priority cascade. Used by commands that accept *either* a hosts
16//!   expression or a group name (kernel-parameters, boot-parameters,
17//!   etc.).
18//!
19//! ## Expression grammar
20//!
21//! A hosts expression is whatever
22//! [`hostlist_parser::parse`] accepts, restricted to one of these
23//! shapes after expansion:
24//!
25//! - **NIDs** — `nidNNNNNN`, exactly 9 characters, e.g.
26//!   `nid000123`. Hostlist notation expands to a list (`nid[001-008]`
27//!   → eight NIDs). NIDs are translated to xnames by looking each
28//!   short NID up in
29//!   [`ComponentTrait::get_node_metadata_available`].
30//! - **xnames** — the full HPE Cray xname regex
31//!   (`x\d{4}c[0-7]s([0-9]|[1-5][0-9]|6[0-4])b[0-1]n[0-7]`).
32//!   Hostlist notation works here too (`x1000c[0-7]s0b0n0`).
33//!
34//! Group names are **not** accepted by the hosts-expression path —
35//! they go through `resolve_target_nodes`'s `group_name_arg_opt`
36//! branch instead.
37//!
38//! With `is_include_siblings = true`, every resolved xname is
39//! broadened to its blade prefix (first 10 chars: `xNNNNcSsBb`) and
40//! every node sharing that prefix is included.
41
42use std::collections::HashMap;
43use std::sync::LazyLock;
44
45use hostlist_parser::parse;
46use manta_backend_dispatcher::{
47  error::Error,
48  interfaces::hsm::{component::ComponentTrait, group::GroupTrait},
49  types::Component,
50};
51use regex::Regex;
52
53// Compile-time constant pattern — .expect() is safe here because
54// the regex literal is known to be valid and will never fail.
55static XNAME_RE: LazyLock<Regex> = LazyLock::new(|| {
56  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]$")
57    .expect("Invalid xname regex pattern")
58});
59
60use crate::server::common::app_context::InfraContext;
61
62/// Length of a NID string, e.g. "nid000001" = 9 characters.
63const NID_STRING_LENGTH: usize = 9;
64
65/// Length of the xname blade prefix, e.g. "x1000c7s0b" = 10 characters.
66const XNAME_BLADE_PREFIX_LEN: usize = 10;
67
68// Validate and get short nid
69fn get_short_nid(long_nid: &str) -> Result<usize, Error> {
70  if long_nid.len() != NID_STRING_LENGTH {
71    return Err(Error::InvalidNodeId(format!(
72      "Nid '{long_nid}' not valid, Nid does not have {NID_STRING_LENGTH} characters"
73    )));
74  }
75
76  let nid_number = long_nid.strip_prefix("nid").ok_or_else(|| {
77    Error::InvalidNodeId(format!(
78      "Nid '{long_nid}' not valid, 'nid' prefix missing"
79    ))
80  })?;
81
82  nid_number.parse::<usize>().map_err(|e| {
83    Error::InvalidNodeId(format!(
84      "Could not convert Nid '{nid_number}' from long to short format: {e}"
85    ))
86  })
87}
88
89/// Resolve a NID hostlist expression to xnames by
90/// cross-referencing available node metadata.
91///
92/// `node_vec` is the already-expanded NID list (every entry must be
93/// the 9-character `nidNNNNNN` form). The lookup builds a single
94/// `HashSet<usize>` of short NIDs and scans `node_metadata_available_vec`
95/// once, so the cost is O(N + M) rather than O(N·M).
96///
97/// # Errors
98///
99/// [`Error::InvalidNodeId`] when an entry is the wrong length, lacks
100/// the `nid` prefix, or has non-numeric digits after the prefix.
101pub fn get_xname_from_nid_hostlist(
102  node_vec: &[String],
103  node_metadata_available_vec: &[Component],
104) -> Result<Vec<String>, Error> {
105  // Convert long nids to short nids
106  // Get xnames from short nids
107  let short_nid_vec: Vec<usize> = node_vec
108    .iter()
109    .map(|nid_long| get_short_nid(nid_long))
110    .collect::<Result<Vec<_>, _>>()?;
111
112  tracing::debug!("short Nid list expanded: {:?}", short_nid_vec);
113
114  // Build a HashSet once so the per-component lookup below is O(1).
115  // The previous `short_nid_vec.contains(&nid)` was O(N) — at cluster
116  // scale (say a hostlist `nid[1-5000]` against ~5k components) that
117  // turned into a 25M-comparison filter on every resolve.
118  let short_nid_set: std::collections::HashSet<usize> =
119    short_nid_vec.iter().copied().collect();
120  let xname_vec: Vec<String> = node_metadata_available_vec
121    .iter()
122    .filter(|node_metadata_available| {
123      node_metadata_available
124        .nid
125        .is_some_and(|nid| short_nid_set.contains(&nid))
126    })
127    .filter_map(|node_metadata_available| {
128      node_metadata_available.id.as_ref().cloned()
129    })
130    .collect();
131
132  Ok(xname_vec)
133}
134
135/// Filter available node metadata to only those xnames
136/// present in `node_vec`.
137///
138/// Inputs not appearing in `node_metadata_available_vec` are silently
139/// dropped — the caller decides whether an empty result should be an
140/// error (see [`from_hosts_expression_to_xname_vec`], which does).
141///
142/// # Errors
143///
144/// Returns `Ok` even when the result is empty; this helper is
145/// infallible at the parse layer.
146pub fn get_xname_from_xname_hostlist(
147  node_vec: &[String],
148  node_metadata_available_vec: &[Component],
149) -> Result<Vec<String>, Error> {
150  // If hostlist of XNAMEs, return hostlist expanded xnames
151  // Validate XNAMEs.
152  //
153  // Hash the requested-xname list once — same reasoning as
154  // `get_xname_from_nid_hostlist`: at cluster scale the
155  // `node_vec.contains(id)` filter was O(N·M).
156  let node_set: std::collections::HashSet<&str> =
157    node_vec.iter().map(String::as_str).collect();
158  let xname_vec: Vec<String> = node_metadata_available_vec
159    .iter()
160    .filter(|node_metadata_available| {
161      node_metadata_available
162        .id
163        .as_ref()
164        .is_some_and(|id| node_set.contains(id.as_str()))
165    })
166    .filter_map(|node_metadata_available| {
167      node_metadata_available.id.as_ref().cloned()
168    })
169    .collect();
170
171  Ok(xname_vec)
172}
173
174/// Convenience wrapper that fetches node metadata from the backend
175/// and resolves a hosts expression to a sorted, deduplicated list
176/// of xnames.
177///
178/// Combines the two-step pattern of
179/// [`ComponentTrait::get_node_metadata_available`] (called on the
180/// backend held in [`InfraContext`]) followed by
181/// [`from_hosts_expression_to_xname_vec`] that recurs in many
182/// command files.
183///
184/// See the module docs for the supported expression grammar.
185///
186/// # Errors
187///
188/// - [`Error::NetError`] / [`Error::CsmError`] from
189///   `get_node_metadata_available`.
190/// - Any error produced by
191///   [`from_hosts_expression_to_xname_vec`]
192///   (`Error::BadRequest`, `Error::InvalidNodeId`).
193pub async fn from_user_hosts_expression_to_xname_vec(
194  infra: &InfraContext<'_>,
195  shasta_token: &str,
196  hosts_expression: &str,
197  is_include_siblings: bool,
198) -> Result<Vec<String>, Error> {
199  let node_metadata_available_vec = infra
200    .backend
201    .get_node_metadata_available(shasta_token)
202    .await?;
203
204  let mut xname_vec = from_hosts_expression_to_xname_vec(
205    hosts_expression,
206    is_include_siblings,
207    &node_metadata_available_vec,
208  )?;
209
210  xname_vec.sort();
211  xname_vec.dedup();
212
213  Ok(xname_vec)
214}
215
216/// Translates a 'host expression' into a list of xnames.
217///
218/// The expression is first run through
219/// [`hostlist_parser::parse`]; the expanded vector is then required to
220/// be **uniformly** NIDs or **uniformly** xnames (mixing the two in a
221/// single expression is rejected). See the module docs for the
222/// supported grammar.
223///
224/// With `is_include_siblings = true`, every resolved xname is widened
225/// to its 10-character blade prefix and every node in
226/// `node_metadata_available_vec` sharing that prefix is included —
227/// this is how `--include-siblings` brings in all four nodes of a
228/// blade when only one was named.
229///
230/// # Errors
231///
232/// - [`Error::InvalidNodeId`] when `hostlist_parser::parse` cannot
233///   tokenize the input.
234/// - [`Error::BadRequest`] when the expanded list is neither all-NID
235///   nor all-xname, or when the final xname set is empty (either
236///   because the expression resolved to nothing, or because the
237///   parser rejected the input).
238pub fn from_hosts_expression_to_xname_vec(
239  user_input: &str,
240  is_include_siblings: bool,
241  node_metadata_available_vec: &[Component],
242) -> Result<Vec<String>, Error> {
243  let hostlist_expanded_vec_rslt =
244    parse(user_input).map_err(|e| Error::InvalidNodeId(e.to_string()));
245
246  let xname_vec = match hostlist_expanded_vec_rslt {
247    Ok(node_vec) => {
248      tracing::debug!("Hostlist format is valid");
249      let xname_vec: Vec<String> = if validate_nid_format_vec(&node_vec) {
250        tracing::debug!("NID format is valid");
251        tracing::debug!("hostlist Nids: {}", user_input);
252        tracing::debug!("hostlist Nids expanded: {:?}", node_vec);
253
254        get_xname_from_nid_hostlist(&node_vec, node_metadata_available_vec)?
255      } else if validate_xname_format_vec(&node_vec) {
256        tracing::debug!("XNAME format is valid");
257        tracing::debug!("hostlist XNAMEs: {}", user_input);
258        tracing::debug!("hostlist XNAMEs expanded: {:?}", node_vec);
259
260        get_xname_from_xname_hostlist(&node_vec, node_metadata_available_vec)?
261      } else {
262        return Err(Error::BadRequest(
263          "Could not parse user input as a list of nodes from a hostlist expression."
264            .to_string(),
265        ));
266      };
267
268      xname_vec
269    }
270    Err(e) => {
271      return Err(Error::BadRequest(format!(
272        "Could not parse user input as a list of nodes from a hostlist or regex expression: {e}"
273      )));
274    }
275  };
276
277  if xname_vec.is_empty() {
278    return Err(Error::BadRequest(
279      "Could not parse user input as a list of nodes from a hostlist or regex expression."
280        .to_string(),
281    ));
282  }
283
284  // Include siblings if requested
285  let xname_vec: Vec<String> = if is_include_siblings {
286    tracing::debug!("Include siblings");
287    let xname_blade_vec: Vec<String> = xname_vec
288      .iter()
289      .map(|xname| {
290        xname
291          .get(0..XNAME_BLADE_PREFIX_LEN)
292          .unwrap_or(xname)
293          .to_string()
294      })
295      .collect();
296
297    tracing::debug!("XNAME blades:\n{:?}", xname_blade_vec);
298
299    // Include siblings: keep any node whose xname shares a blade
300    // prefix with one of the resolved xnames.
301    node_metadata_available_vec
302      .iter()
303      .filter(|node_metadata_available| {
304        node_metadata_available.id.as_ref().is_some_and(|id| {
305          xname_blade_vec
306            .iter()
307            .any(|xname_blade| id.starts_with(xname_blade))
308        })
309      })
310      .filter_map(|node_metadata_available| node_metadata_available.id.as_ref())
311      .cloned()
312      .collect()
313  } else {
314    xname_vec
315  };
316
317  Ok(xname_vec)
318}
319
320/// Group the supplied xnames by their parent HSM group.
321///
322/// Fetches the HSM groups the caller can access, then for each group
323/// returns the intersection of its membership with `xname_vec`.
324/// Groups whose intersection is empty are omitted, so the returned
325/// map contains only groups that actually contribute at least one
326/// matching node.
327///
328/// Used by [`crate::service::migrate::migrate_nodes`] to slice a
329/// single resolved xname list across multiple parent groups for the
330/// per-pair `migrate_group_members` calls.
331///
332/// # Errors
333///
334/// [`Error::NetError`] / [`Error::CsmError`] from
335/// `get_group_name_available` or `get_group_map_and_filter_by_group_vec`.
336pub async fn get_curated_group_from_xname_hostlist(
337  infra: &InfraContext<'_>,
338  auth_token: &str,
339  xname_vec: &[String],
340) -> Result<HashMap<String, Vec<String>>, Error> {
341  let mut hsm_group_summary: HashMap<String, Vec<String>> = HashMap::new();
342
343  let hsm_name_available_vec =
344    infra.backend.get_group_name_available(auth_token).await?;
345
346  let names_ref: Vec<&str> =
347    hsm_name_available_vec.iter().map(String::as_str).collect();
348  let hsm_group_available_map = infra
349    .backend
350    .get_group_map_and_filter_by_group_vec(auth_token, &names_ref)
351    .await?;
352
353  // Filter hsm group members. Pre-compute a hash of the requested
354  // xname set once — the outer loop is over groups and the inner
355  // `xname_vec.contains(xname)` would otherwise re-scan the full
356  // requested list per member per group (groups × members × xnames).
357  let xname_set: std::collections::HashSet<&str> =
358    xname_vec.iter().map(String::as_str).collect();
359  for (hsm_name, hsm_members) in hsm_group_available_map {
360    let xname_filtered: Vec<String> = hsm_members
361      .iter()
362      .filter(|xname| xname_set.contains(xname.as_str()))
363      .cloned()
364      .collect();
365    if !xname_filtered.is_empty() {
366      hsm_group_summary.insert(hsm_name, xname_filtered);
367    }
368  }
369
370  Ok(hsm_group_summary)
371}
372
373fn validate_nid_format_vec(node_vec: &[String]) -> bool {
374  node_vec.iter().all(|nid| validate_nid_format(nid))
375}
376
377fn validate_nid_format(nid: &str) -> bool {
378  nid.to_lowercase().starts_with("nid")
379    && nid.len() == 9
380    && nid
381      .strip_prefix("nid")
382      .is_some_and(|nid_number| nid_number.chars().all(char::is_numeric))
383}
384
385fn validate_xname_format_vec(node_vec: &[String]) -> bool {
386  node_vec.iter().all(|nid| validate_xname_format(nid))
387}
388
389/// Return `true` if `xname` matches the HPE Cray xname regex.
390pub(crate) fn validate_xname_format(xname: &str) -> bool {
391  XNAME_RE.is_match(xname)
392}
393
394/// Resolve target nodes from either a hosts expression, an
395/// explicit HSM group name, or the settings-level HSM group.
396///
397/// Priority order (first non-`None` wins):
398/// 1. `hosts_expression_opt` — parsed and validated via
399///    [`from_user_hosts_expression_to_xname_vec`]. Returns a sorted,
400///    deduplicated `Vec<String>` of xnames.
401/// 2. `group_name_arg_opt` — the group name supplied by the CLI's
402///    `--group` flag (also accepted as `--hsm-group`); validated for
403///    access via
404///    [`crate::service::authorization::validate_user_group_access`],
405///    then expanded to member xnames.
406/// 3. `settings_group_name_opt` — the group configured in
407///    `cli.toml`'s `hsm_group`; same treatment as (2).
408///
409/// # Errors
410///
411/// - [`Error::BadRequest`] when all three options are `None`, or when
412///   the caller lacks access to the chosen group.
413/// - Any error from
414///   [`from_user_hosts_expression_to_xname_vec`] in the
415///   hosts-expression branch.
416/// - [`Error::NetError`] / [`Error::CsmError`] from
417///   `get_member_vec_from_group_name_vec`.
418pub async fn resolve_target_nodes(
419  infra: &InfraContext<'_>,
420  token: &str,
421  hosts_expression_opt: Option<&str>,
422  group_name_arg_opt: Option<&str>,
423  settings_group_name_opt: Option<&str>,
424) -> Result<Vec<String>, Error> {
425  if let Some(hosts_expr) = hosts_expression_opt {
426    from_user_hosts_expression_to_xname_vec(infra, token, hosts_expr, false)
427      .await
428  } else if let Some(target_group) =
429    group_name_arg_opt.or(settings_group_name_opt)
430  {
431    crate::service::authorization::validate_user_group_access(
432      infra,
433      token,
434      target_group,
435    )
436    .await?;
437
438    infra
439      .backend
440      .get_member_vec_from_group_name_vec(token, &[target_group.to_string()])
441      .await
442  } else {
443    Err(Error::BadRequest(
444      "No nodes provided. Please provide either a list of nodes \
445       via --nodes or an HSM group via --hsm-group"
446        .to_string(),
447    ))
448  }
449}
450
451#[cfg(test)]
452mod tests;