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;