manta_server/server/common/
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::manta_backend_dispatcher::StaticBackendDispatcher;
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 async 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  let xname_vec: Vec<String> = node_metadata_available_vec
68    .iter()
69    .filter(|node_metadata_available| {
70      node_metadata_available
71        .nid
72        .is_some_and(|nid| short_nid_vec.contains(&nid))
73    })
74    .filter_map(|node_metadata_available| {
75      node_metadata_available.id.as_ref().cloned()
76    })
77    .collect();
78
79  Ok(xname_vec)
80}
81
82/// Filter available node metadata to only those xnames
83/// present in `node_vec`.
84pub async fn get_xname_from_xname_hostlist(
85  node_vec: &[String],
86  node_metadata_available_vec: &[Component],
87) -> Result<Vec<String>, Error> {
88  // If hostlist of XNAMEs, return hostlist expanded xnames
89  // Validate XNAMEs
90  let xname_vec: Vec<String> = node_metadata_available_vec
91    .iter()
92    .filter(|node_metadata_available| {
93      node_metadata_available
94        .id
95        .as_ref()
96        .is_some_and(|id| node_vec.contains(id))
97    })
98    .filter_map(|node_metadata_available| {
99      node_metadata_available.id.as_ref().cloned()
100    })
101    .collect();
102
103  Ok(xname_vec)
104}
105
106// Unused get_xname_from_nid_regex removed
107
108// Unused get_xname_from_xname_regex removed
109
110/// Convenience wrapper that fetches node metadata from the backend
111/// and resolves a hosts expression to a sorted, deduplicated list
112/// of xnames.
113///
114/// This combines the two-step pattern of
115/// `backend.get_node_metadata_available()` followed by
116/// `from_hosts_expression_to_xname_vec()` that appears in many
117/// command files.
118pub async fn resolve_hosts_expression(
119  backend: &StaticBackendDispatcher,
120  shasta_token: &str,
121  hosts_expression: &str,
122  is_include_siblings: bool,
123) -> Result<Vec<String>, Error> {
124  let node_metadata_available_vec =
125    backend.get_node_metadata_available(shasta_token).await?;
126
127  let mut xname_vec = from_hosts_expression_to_xname_vec(
128    hosts_expression,
129    is_include_siblings,
130    node_metadata_available_vec,
131  )
132  .await?;
133
134  xname_vec.sort();
135  xname_vec.dedup();
136
137  Ok(xname_vec)
138}
139
140/// Translates and filters a 'host expression' into a list of xnames.
141/// a host expression is a comma separated list of NIDs or XNAMEs, a regex or a hostlist
142/// NOTE: user can provice a host expression and expand the list to all siblings
143pub async fn from_hosts_expression_to_xname_vec(
144  user_input: &str,
145  is_include_siblings: bool,
146  node_metadata_available_vec: Vec<Component>,
147) -> Result<Vec<String>, Error> {
148  let hostlist_expanded_vec_rslt =
149    parse(user_input).map_err(|e| Error::InvalidNodeId(e.to_string()));
150
151  let xname_vec = match hostlist_expanded_vec_rslt {
152    Ok(node_vec) => {
153      tracing::debug!("Hostlist format is valid");
154      let xname_vec: Vec<String> = if validate_nid_format_vec(&node_vec) {
155        tracing::debug!("NID format is valid");
156        tracing::debug!("hostlist Nids: {}", user_input);
157        tracing::debug!("hostlist Nids expanded: {:?}", node_vec);
158
159        get_xname_from_nid_hostlist(&node_vec, &node_metadata_available_vec)
160          .await?
161      } else if validate_xname_format_vec(&node_vec) {
162        tracing::debug!("XNAME format is valid");
163        tracing::debug!("hostlist XNAMEs: {}", user_input);
164        tracing::debug!("hostlist XNAMEs expanded: {:?}", node_vec);
165
166        get_xname_from_xname_hostlist(&node_vec, &node_metadata_available_vec)
167          .await?
168      } else {
169        return Err(Error::BadRequest(
170          "Could not parse user input as a list of nodes from a hostlist expression."
171            .to_string(),
172        ));
173      };
174
175      xname_vec
176    }
177    Err(e) => {
178      return Err(Error::BadRequest(format!(
179        "Could not parse user input as a list of nodes from a hostlist or regex expression: {e}"
180      )));
181    }
182  };
183
184  if xname_vec.is_empty() {
185    return Err(Error::BadRequest(
186      "Could not parse user input as a list of nodes from a hostlist or regex expression."
187        .to_string(),
188    ));
189  }
190
191  // Include siblings if requested
192  let xname_vec: Vec<String> = if is_include_siblings {
193    tracing::debug!("Include siblings");
194    let xname_blade_vec: Vec<String> = xname_vec
195      .iter()
196      .map(|xname| {
197        xname
198          .get(0..XNAME_BLADE_PREFIX_LEN)
199          .unwrap_or(xname)
200          .to_string()
201      })
202      .collect();
203
204    tracing::debug!("XNAME blades:\n{:?}", xname_blade_vec);
205
206    // Filter xnames to the ones the user has access to
207
208    node_metadata_available_vec
209      .into_iter()
210      .filter(|node_metadata_available| {
211        node_metadata_available.id.as_ref().is_some_and(|id| {
212          xname_blade_vec
213            .iter()
214            .any(|xname_blade| id.starts_with(xname_blade))
215        })
216      })
217      .filter_map(|node_metadata_available| node_metadata_available.id)
218      .collect()
219  } else {
220    xname_vec
221  };
222
223  Ok(xname_vec)
224}
225
226/// Returns a HashMap with keys HSM group names the user has access to and values a curated list of memembers that matches
227/// hostlist
228pub async fn get_curated_hsm_group_from_xname_hostlist(
229  backend: &StaticBackendDispatcher,
230  auth_token: &str,
231  xname_vec: &[String],
232) -> Result<HashMap<String, Vec<String>>, Error> {
233  let mut hsm_group_summary: HashMap<String, Vec<String>> = HashMap::new();
234
235  let hsm_name_available_vec =
236    backend.get_group_name_available(auth_token).await?;
237
238  let hsm_group_available_map = backend
239    .get_group_map_and_filter_by_group_vec(
240      auth_token,
241      &hsm_name_available_vec
242        .iter()
243        .map(String::as_str)
244        .collect::<Vec<&str>>(),
245    )
246    .await?;
247
248  // Filter hsm group members
249  for (hsm_name, hsm_members) in hsm_group_available_map {
250    let xname_filtered: Vec<String> = hsm_members
251      .iter()
252      .filter(|&xname| xname_vec.contains(xname))
253      .cloned()
254      .collect();
255    if !xname_filtered.is_empty() {
256      hsm_group_summary.insert(hsm_name, xname_filtered);
257    }
258  }
259
260  Ok(hsm_group_summary)
261}
262
263fn validate_nid_format_vec(node_vec: &[String]) -> bool {
264  node_vec.iter().all(|nid| validate_nid_format(nid))
265}
266
267fn validate_nid_format(nid: &str) -> bool {
268  nid.to_lowercase().starts_with("nid")
269    && nid.len() == 9
270    && nid
271      .strip_prefix("nid")
272      .is_some_and(|nid_number| nid_number.chars().all(char::is_numeric))
273}
274
275fn validate_xname_format_vec(node_vec: &[String]) -> bool {
276  node_vec.iter().all(|nid| validate_xname_format(nid))
277}
278
279/// Return `true` if `xname` matches the HPE Cray xname regex.
280pub fn validate_xname_format(xname: &str) -> bool {
281  XNAME_RE.is_match(xname)
282}
283
284// `string_vec_to_multi_line_string` (display-only helper) moved to
285// `crate::cli::common::display::string_vec_to_multi_line_string`.
286// The server never used it; its tests moved alongside it.
287
288/// Resolve target nodes from either a hosts expression, an
289/// explicit HSM group name, or the settings-level HSM group.
290///
291/// Priority order:
292/// 1. `hosts_expression` — parsed and validated via
293///    [`resolve_hosts_expression`].
294/// 2. `hsm_group_name_arg_opt` — the CLI `--hsm-group`
295///    argument; validated for access via
296///    [`crate::server::common::authorization::get_groups_names_available`],
297///    then expanded to member xnames.
298/// 3. `settings_hsm_group_name_opt` — the group configured in
299///    the environment or config file; same treatment as (2).
300///
301/// Returns a sorted, deduplicated `Vec<String>` of xnames.
302pub async fn resolve_target_nodes(
303  backend: &StaticBackendDispatcher,
304  shasta_token: &str,
305  hosts_expression: Option<&str>,
306  hsm_group_name_arg_opt: Option<&str>,
307  settings_hsm_group_name_opt: Option<&str>,
308) -> Result<Vec<String>, Error> {
309  if let Some(hosts_expr) = hosts_expression {
310    resolve_hosts_expression(backend, shasta_token, hosts_expr, false).await
311  } else if hsm_group_name_arg_opt.is_some()
312    || settings_hsm_group_name_opt.is_some()
313  {
314    let hsm_group_name_vec =
315      crate::server::common::authorization::get_groups_names_available(
316        backend,
317        shasta_token,
318        hsm_group_name_arg_opt,
319        settings_hsm_group_name_opt,
320      )
321      .await?;
322
323    let hsm_members: Vec<String> = backend
324      .get_member_vec_from_group_name_vec(shasta_token, &hsm_group_name_vec)
325      .await?;
326
327    resolve_hosts_expression(
328      backend,
329      shasta_token,
330      &hsm_members.join(","),
331      false,
332    )
333    .await
334  } else {
335    Err(Error::BadRequest(
336      "No nodes provided. Please provide either a list of nodes \
337       via --nodes or an HSM group via --hsm-group"
338        .to_string(),
339    ))
340  }
341}
342
343#[cfg(test)]
344mod tests;