manta_server/service/hw_cluster/
apply.rs

1//! High-level coordinators for hardware-cluster mutations.
2//!
3//! Three entry points exposed to handlers:
4//!
5//! - [`apply_hw_configuration`] — pin/unpin entry point. Given a
6//!   target group, parent group, and component pattern, balance the
7//!   two groups so the target satisfies the pattern.
8//! - [`add_hw_component`] — pull nodes from the parent into the
9//!   target to *increase* the target's hardware count by `pattern`'s
10//!   deltas.
11//! - [`delete_hw_component`] — push nodes back from the target to the
12//!   parent to *decrease* the target's hardware count by `pattern`'s
13//!   deltas.
14//!
15//! Every function shares the same skeleton: parse the pattern, fetch
16//! both groups' hardware inventories (one
17//! [`super::scoring::fetch_group_hw_inventory`] call per group, each
18//! itself fan-outs across the group members), compute scarcity
19//! scores so common components are pulled first, and finally issue
20//! the per-node `add_members_to_group` / `delete_member_from_group`
21//! moves. `dryrun` short-circuits every backend write but still
22//! reports the would-be membership.
23
24use std::collections::HashMap;
25
26use manta_backend_dispatcher::{
27  error::Error, interfaces::hsm::group::GroupTrait, types::Group,
28};
29
30use super::{
31  AddHwResult, ApplyHwResult, DeleteHwResult, HwClusterMode,
32  MEMORY_CAPACITY_LCM, pin_unpin, scoring,
33};
34use crate::server::common::app_context::InfraContext;
35
36/// Pin or unpin nodes between `parent_group_name` and
37/// `target_group_name` so the target group satisfies `pattern`.
38///
39/// The flow is parse → ensure → score → resolve → apply: the
40/// component pattern is parsed into a counts map, the target group is
41/// created on demand (or refused when `create_target_group` is false
42/// and the group is missing), parent and target hardware inventories
43/// are fetched, the resource-sufficiency check rejects patterns that
44/// ask for more of a component than exists in the pool, and `mode`
45/// (`Pin` / `Unpin`) picks which selection algorithm runs. `dryrun`
46/// shortcuts every backend mutation but still returns the would-be
47/// final memberships so the operator sees the plan.
48/// Parameters for [`apply_hw_configuration`].
49pub struct ApplyHwConfigurationParams<'a> {
50  /// `Pin` (capacity-aware selection) or `Unpin` (release all).
51  pub mode: HwClusterMode,
52  /// Destination HSM group that will receive nodes matching `pattern`.
53  pub target_group_name: &'a str,
54  /// Source HSM group nodes are drawn from when honouring `pattern`.
55  pub parent_group_name: &'a str,
56  /// Hardware-component request string, e.g. `"a100:8,milan:2"`.
57  pub pattern: &'a str,
58  /// When `true`, plan the moves but skip every backend mutation; the
59  /// returned `ApplyHwResult` still reflects the would-be membership.
60  pub dryrun: bool,
61  /// Create `target_group_name` if it doesn't already exist.
62  pub create_target_group: bool,
63  /// Delete the parent group when the move leaves it with no members.
64  pub delete_empty_parent_group: bool,
65}
66
67/// Service entry point for `POST /hardware-clusters/{target}/configuration`.
68///
69/// # Errors
70///
71/// - [`Error::InvalidPattern`] when `p.pattern` cannot be parsed.
72/// - [`Error::NotFound`] when the target group is missing and
73///   `p.create_target_group` is false.
74/// - [`Error::BadRequest`] when a dry-run would require creating the
75///   target group.
76/// - [`Error::InsufficientResources`] when the parent group cannot
77///   supply enough of any component named in the pattern.
78/// - [`Error::NetError`] / [`Error::CsmError`] from any of the
79///   `get_group` / hardware-inventory / membership-mutation backend
80///   calls.
81pub async fn apply_hw_configuration(
82  infra: &InfraContext<'_>,
83  shasta_token: &str,
84  p: ApplyHwConfigurationParams<'_>,
85) -> Result<ApplyHwResult, Error> {
86  let ApplyHwConfigurationParams {
87    mode,
88    target_group_name,
89    parent_group_name,
90    pattern,
91    dryrun,
92    create_target_group,
93    delete_empty_parent_group,
94  } = p;
95  let (user_defined_hw_component_vec, user_defined_hw_component_count_hashmap) =
96    pin_unpin::parse_hw_pattern_usize(target_group_name, pattern)?;
97
98  pin_unpin::ensure_target_group_exists(
99    infra,
100    shasta_token,
101    target_group_name,
102    dryrun,
103    create_target_group,
104  )
105  .await?;
106
107  let (
108    (
109      target_hsm_group_member_vec,
110      target_hsm_node_hw_component_count_vec,
111      target_hsm_hw_component_summary,
112    ),
113    (
114      parent_hsm_group_member_vec,
115      parent_hsm_node_hw_component_count_vec,
116      _parent_summary,
117    ),
118  ) = tokio::try_join!(
119    scoring::fetch_group_hw_inventory(
120      infra,
121      shasta_token,
122      &user_defined_hw_component_vec,
123      target_group_name,
124      MEMORY_CAPACITY_LCM,
125    ),
126    scoring::fetch_group_hw_inventory(
127      infra,
128      shasta_token,
129      &user_defined_hw_component_vec,
130      parent_group_name,
131      MEMORY_CAPACITY_LCM,
132    ),
133  )?;
134
135  tracing::info!(
136    "HSM group '{}' hw component summary: {:?}",
137    target_group_name,
138    target_hsm_hw_component_summary
139  );
140
141  pin_unpin::validate_resource_sufficiency(
142    &target_hsm_node_hw_component_count_vec,
143    &parent_hsm_node_hw_component_count_vec,
144    &user_defined_hw_component_count_hashmap,
145  )?;
146
147  let (
148    target_hsm_node_hw_component_count_vec,
149    parent_hsm_node_hw_component_count_vec,
150  ) = scoring::resolve_hw_description_to_xnames(
151    mode,
152    target_hsm_node_hw_component_count_vec,
153    parent_hsm_node_hw_component_count_vec,
154    &user_defined_hw_component_count_hashmap,
155  )?;
156
157  let target_hsm_node_vec: Vec<String> = target_hsm_node_hw_component_count_vec
158    .into_iter()
159    .map(|(xname, _)| xname)
160    .collect();
161
162  let parent_hsm_node_vec: Vec<String> = parent_hsm_node_hw_component_count_vec
163    .into_iter()
164    .map(|(xname, _)| xname)
165    .collect();
166
167  pin_unpin::apply_group_updates(
168    infra,
169    shasta_token,
170    pin_unpin::GroupUpdate {
171      target_group: target_group_name,
172      parent_group: parent_group_name,
173      old_target_members: &target_hsm_group_member_vec,
174      old_parent_members: &parent_hsm_group_member_vec,
175      new_target_members: &target_hsm_node_vec,
176      new_parent_members: &parent_hsm_node_vec,
177      dryrun,
178      delete_empty_parent: delete_empty_parent_group,
179    },
180  )
181  .await?;
182
183  Ok(ApplyHwResult {
184    target_nodes: target_hsm_node_vec,
185    parent_nodes: parent_hsm_node_vec,
186  })
187}
188
189// ── add_hw_component ─────────────────────────────────────────────────────────
190
191/// Ensure the target HSM group exists for add-hw-component, creating it if needed.
192async fn ensure_add_target_group_exists(
193  infra: &InfraContext<'_>,
194  shasta_token: &str,
195  target_hsm_group_name: &str,
196  dryrun: bool,
197  create_hsm_group: bool,
198) -> Result<(), Error> {
199  if infra
200    .backend
201    .get_group(shasta_token, target_hsm_group_name)
202    .await
203    .is_ok()
204  {
205    tracing::debug!("The group '{}' exists, good.", target_hsm_group_name);
206    return Ok(());
207  }
208  if !create_hsm_group {
209    return Err(Error::NotFound(format!(
210      "Group '{target_hsm_group_name}' does not exist, but the \
211       option to create the group was NOT \
212       specified, cannot continue."
213    )));
214  }
215  tracing::info!(
216    "Group '{}' does not exist, but the option \
217     to create the group has been selected, \
218     creating it now.",
219    target_hsm_group_name
220  );
221  if dryrun {
222    return Err(Error::BadRequest(
223      "Dryrun selected, cannot create \
224       the new group and continue."
225        .to_string(),
226    ));
227  }
228  let group = Group {
229    label: target_hsm_group_name.to_string(),
230    description: None,
231    tags: None,
232    members: None,
233    exclusive_group: Some("false".to_string()),
234  };
235  infra.backend.add_group(shasta_token, group).await?;
236  Ok(())
237}
238
239/// Compute the final parent HSM hw component summary after subtracting user-requested deltas.
240//
241// `deltas` carries signed counters (`isize`) because callers compute
242// the difference between current and target counts; in practice the
243// values are non-negative HW component subtractions. The
244// `*counter as usize` cast is guarded by the explicit
245// `if *counter > current as isize` overflow check above each call site.
246#[allow(clippy::cast_sign_loss)]
247fn compute_final_parent_summary(
248  current_summary: &HashMap<String, usize>,
249  deltas: &HashMap<String, isize>,
250  parent_group_name: &str,
251) -> Result<HashMap<String, usize>, Error> {
252  let mut final_summary: HashMap<String, usize> = HashMap::new();
253
254  for (hw_component, counter) in deltas {
255    let current = *current_summary.get(hw_component).unwrap_or(&0);
256    if *counter > current.cast_signed() {
257      return Err(Error::InsufficientResources(format!(
258        "Cannot remove more hw component '{}' \
259         ({}) than available in parent group \
260         '{}' ({})",
261        hw_component, *counter, parent_group_name, current
262      )));
263    }
264    let new_counter = current - *counter as usize;
265    final_summary.insert(hw_component.clone(), new_counter);
266  }
267
268  Ok(final_summary)
269}
270
271/// Move enough nodes out of `parent_group_name` into
272/// `target_group_name` to add the components described by
273/// `pattern` (`<component>:<delta>` pairs) to the target.
274///
275/// The target group is created on demand when `create_group` is
276/// set; missing it otherwise yields `NotFound`. The parent group's
277/// post-move hw component summary is computed up front so the
278/// algorithm can reject patterns that would over-draw the parent
279/// (`InsufficientResources`). Selection uses scarcity-weighted scores
280/// so common components get pulled first and rare ones are preserved.
281/// In `dryrun` mode the planned move is returned without any backend
282/// mutation.
283///
284/// # Errors
285///
286/// - [`Error::NotFound`] when the target group is missing and
287///   `create_group` is false.
288/// - [`Error::BadRequest`] for a dry-run that would otherwise create
289///   the missing target group.
290/// - [`Error::InvalidPattern`] when `pattern` cannot be parsed.
291/// - [`Error::InsufficientResources`] when removing any component
292///   would over-draw the parent group.
293/// - [`Error::NetError`] / [`Error::CsmError`] from the backend
294///   group / inventory / membership calls.
295pub async fn add_hw_component(
296  infra: &InfraContext<'_>,
297  shasta_token: &str,
298  target_group_name: &str,
299  parent_group_name: &str,
300  pattern: &str,
301  dryrun: bool,
302  create_group: bool,
303) -> Result<AddHwResult, Error> {
304  ensure_add_target_group_exists(
305    infra,
306    shasta_token,
307    target_group_name,
308    dryrun,
309    create_group,
310  )
311  .await?;
312
313  let pattern_str = format!("{target_group_name}:{pattern}");
314  let pattern_lowercase = pattern_str.to_lowercase();
315  let mut pattern_element_vec: Vec<&str> =
316    pattern_lowercase.split(':').collect();
317  let target_name = pattern_element_vec.remove(0);
318
319  let (
320    user_defined_delta_hw_component_vec,
321    user_defined_delta_hw_component_count_hashmap,
322  ) = scoring::parse_hw_pattern(&pattern_element_vec)?;
323
324  let (
325    _parent_member_vec,
326    mut parent_hsm_node_hw_component_count_vec,
327    parent_hsm_hw_component_summary,
328  ) = scoring::fetch_group_hw_inventory(
329    infra,
330    shasta_token,
331    &user_defined_delta_hw_component_vec,
332    parent_group_name,
333    MEMORY_CAPACITY_LCM,
334  )
335  .await?;
336
337  let final_parent_hsm_hw_component_summary = compute_final_parent_summary(
338    &parent_hsm_hw_component_summary,
339    &user_defined_delta_hw_component_count_hashmap,
340    parent_group_name,
341  )?;
342
343  let scarcity_scores = scoring::calculate_hw_component_scarcity_scores(
344    &parent_hsm_node_hw_component_count_vec,
345  );
346
347  let hw_counters_to_move = pin_unpin::calculate_target_group_unpin(
348    &final_parent_hsm_hw_component_summary,
349    &final_parent_hsm_hw_component_summary
350      .keys()
351      .cloned()
352      .collect::<Vec<String>>(),
353    &mut parent_hsm_node_hw_component_count_vec,
354    &scarcity_scores,
355  )?;
356
357  let nodes_to_move: Vec<String> = hw_counters_to_move
358    .iter()
359    .map(|(xname, _)| xname.clone())
360    .collect();
361
362  let mut target_hsm_node_vec: Vec<String> = infra
363    .backend
364    .get_member_vec_from_group_name_vec(
365      shasta_token,
366      &[target_name.to_string()],
367    )
368    .await?;
369
370  target_hsm_node_vec.extend(nodes_to_move.clone());
371  target_hsm_node_vec.sort();
372
373  if !dryrun {
374    futures::future::try_join_all(nodes_to_move.iter().map(
375      |xname| async move {
376        // delete-then-add is sequential within each node to avoid
377        // transient dual-group membership; the outer fan-out is safe
378        // because each xname is independent.
379        infra
380          .backend
381          .delete_member_from_group(shasta_token, parent_group_name, xname)
382          .await?;
383        infra
384          .backend
385          .add_members_to_group(shasta_token, target_name, &[xname.as_str()])
386          .await?;
387        Ok::<(), Error>(())
388      },
389    ))
390    .await?;
391  }
392
393  let parent_nodes: Vec<String> = parent_hsm_node_hw_component_count_vec
394    .iter()
395    .map(|(xname, _)| xname.clone())
396    .collect();
397
398  Ok(AddHwResult {
399    nodes_moved: nodes_to_move,
400    target_nodes: target_hsm_node_vec,
401    parent_nodes,
402  })
403}
404
405// ── delete_hw_component ──────────────────────────────────────────────────────
406
407/// Handle the case when target HSM group is already empty.
408async fn handle_empty_target(
409  infra: &InfraContext<'_>,
410  shasta_token: &str,
411  target_hsm_group_name: &str,
412  dryrun: bool,
413  delete_hsm_group: bool,
414) -> Result<(), Error> {
415  tracing::info!(
416    "The target HSM group {} is already empty, cannot \
417     remove hardware from it.",
418    target_hsm_group_name
419  );
420
421  if dryrun || !delete_hsm_group {
422    tracing::info!(
423      "The option to delete empty groups has NOT been \
424       selected, or the dryrun has been enabled. We \
425       are done with this action."
426    );
427    return Ok(());
428  }
429
430  tracing::info!(
431    "The option to delete empty groups has been \
432     selected, removing it."
433  );
434  match infra
435    .backend
436    .delete_group(shasta_token, target_hsm_group_name)
437    .await
438  {
439    Ok(_) => {
440      tracing::info!(
441        "HSM group removed successfully, we are \
442         done with this action."
443      );
444    }
445    Err(e) => tracing::debug!(
446      "Error removing the HSM group. This always \
447       fails, ignore please. Reported: {}",
448      e
449    ),
450  }
451  Ok(())
452}
453
454/// Compute the final target HSM hw component summary after subtracting deltas.
455//
456// Same `isize → usize` cast rationale as `compute_final_parent_summary`:
457// callers compute non-negative HW deltas; the cast preserves intent.
458#[allow(clippy::cast_sign_loss)]
459fn compute_delete_final_summary(
460  current_summary: &HashMap<String, usize>,
461  deltas: &HashMap<String, isize>,
462) -> Result<HashMap<String, usize>, Error> {
463  let mut final_summary: HashMap<String, usize> = HashMap::new();
464
465  for (hw_component, counter) in deltas {
466    let current = *current_summary.get(hw_component).ok_or_else(|| {
467      Error::NotFound(format!(
468        "hw component '{hw_component}' not found in target HSM \
469           hw component summary"
470      ))
471    })?;
472
473    final_summary.insert(hw_component.clone(), current - *counter as usize);
474  }
475
476  Ok(final_summary)
477}
478
479/// Move nodes between HSM groups: delete from target, add to parent.
480async fn apply_node_moves(
481  infra: &InfraContext<'_>,
482  shasta_token: &str,
483  target_group: &str,
484  parent_group: &str,
485  nodes: &[String],
486  target_will_be_empty: bool,
487  delete_hsm_group: bool,
488) -> Result<(), Error> {
489  futures::future::try_join_all(nodes.iter().map(|xname| async move {
490    // delete-then-add is sequential within each node to avoid
491    // transient dual-group membership; the outer fan-out is safe
492    // because each xname is independent.
493    infra
494      .backend
495      .delete_member_from_group(shasta_token, target_group, xname.as_str())
496      .await?;
497    infra
498      .backend
499      .add_members_to_group(shasta_token, parent_group, &[xname.as_str()])
500      .await?;
501    Ok::<(), Error>(())
502  }))
503  .await?;
504
505  if target_will_be_empty {
506    if delete_hsm_group {
507      tracing::info!(
508        "HSM group {} is now empty and the option to \
509         delete empty groups has been selected, \
510         removing it.",
511        target_group
512      );
513      match infra.backend.delete_group(shasta_token, target_group).await {
514        Ok(_) => tracing::info!("HSM group removed successfully."),
515        Err(e) => tracing::debug!(
516          "Error removing the HSM group. This always \
517           fails, ignore please. Reported: {}",
518          e
519        ),
520      }
521    } else {
522      tracing::debug!(
523        "HSM group {} is now empty and the option to \
524         delete empty groups has NOT been selected, \
525         will not remove it.",
526        target_group
527      );
528    }
529  }
530
531  Ok(())
532}
533
534/// Move enough nodes out of `target_group_name` back into
535/// `parent_group_name` to remove the components described by
536/// `pattern` from the target.
537///
538/// The target group must already exist (returns `NotFound`
539/// otherwise). When the target is already empty the routine
540/// short-circuits, optionally deleting the empty group if
541/// `delete_group` is set. Selection scores combine both groups'
542/// scarcity, so the move keeps the most scarce hardware in the target
543/// group whenever possible. After moving, the function deletes the
544/// target group if it ended up empty and `delete_group` is true.
545/// `dryrun` returns the planned move without touching the backend.
546///
547/// # Errors
548///
549/// - [`Error::NotFound`] when the target group is missing, or when
550///   `pattern` names a component absent from the target's summary.
551/// - [`Error::InvalidPattern`] when `pattern` cannot be parsed.
552/// - [`Error::NetError`] / [`Error::CsmError`] from the backend
553///   group / inventory / membership calls.
554pub async fn delete_hw_component(
555  infra: &InfraContext<'_>,
556  token: &str,
557  target_group_name: &str,
558  parent_group_name: &str,
559  pattern: &str,
560  dryrun: bool,
561  delete_group: bool,
562) -> Result<DeleteHwResult, Error> {
563  match infra.backend.get_group(token, target_group_name).await {
564    Ok(_) => {}
565    Err(_) => {
566      return Err(Error::NotFound(format!(
567        "HSM group {target_group_name} does not exist, cannot remove hw from it."
568      )));
569    }
570  }
571
572  let pattern_str = format!("{target_group_name}:{pattern}");
573  let pattern_lowercase = pattern_str.to_lowercase();
574  let mut pattern_element_vec: Vec<&str> =
575    pattern_lowercase.split(':').collect();
576  let target_name = pattern_element_vec.remove(0);
577
578  let (
579    user_defined_delta_hw_component_vec,
580    user_defined_delta_hw_component_count_hashmap,
581  ) = scoring::parse_hw_pattern(&pattern_element_vec)?;
582
583  let (
584    (
585      target_hsm_group_member_vec,
586      mut target_hsm_node_hw_component_count_vec,
587      target_hsm_hw_component_summary,
588    ),
589    (
590      parent_hsm_group_member_vec,
591      parent_hsm_node_hw_component_count_vec,
592      _parent_summary,
593    ),
594  ) = tokio::try_join!(
595    scoring::fetch_group_hw_inventory(
596      infra,
597      token,
598      &user_defined_delta_hw_component_vec,
599      target_name,
600      MEMORY_CAPACITY_LCM,
601    ),
602    scoring::fetch_group_hw_inventory(
603      infra,
604      token,
605      &user_defined_delta_hw_component_vec,
606      parent_group_name,
607      MEMORY_CAPACITY_LCM,
608    ),
609  )?;
610
611  if target_hsm_node_hw_component_count_vec.is_empty() {
612    handle_empty_target(infra, token, target_name, dryrun, delete_group)
613      .await?;
614    return Ok(DeleteHwResult {
615      nodes_moved: vec![],
616      target_nodes: vec![],
617      parent_nodes: vec![],
618    });
619  }
620
621  let combined = [
622    target_hsm_node_hw_component_count_vec.clone(),
623    parent_hsm_node_hw_component_count_vec.clone(),
624  ]
625  .concat();
626  let scarcity_scores =
627    scoring::calculate_hw_component_scarcity_scores(&combined);
628
629  let final_target_summary = compute_delete_final_summary(
630    &target_hsm_hw_component_summary,
631    &user_defined_delta_hw_component_count_hashmap,
632  )?;
633
634  let hw_counters_to_move = pin_unpin::calculate_target_group_unpin(
635    &final_target_summary,
636    &final_target_summary
637      .keys()
638      .cloned()
639      .collect::<Vec<String>>(),
640    &mut target_hsm_node_hw_component_count_vec,
641    &scarcity_scores,
642  )?;
643
644  let nodes_to_move: Vec<String> = hw_counters_to_move
645    .iter()
646    .map(|(xname, _)| xname.clone())
647    .collect();
648
649  let mut parent_nodes: Vec<String> = parent_hsm_group_member_vec;
650  parent_nodes.extend(nodes_to_move.clone());
651  parent_nodes.sort();
652
653  let target_nodes: Vec<String> = target_hsm_node_hw_component_count_vec
654    .iter()
655    .map(|(xname, _)| xname.clone())
656    .collect();
657
658  if !dryrun {
659    apply_node_moves(
660      infra,
661      token,
662      target_name,
663      parent_group_name,
664      &nodes_to_move,
665      target_hsm_group_member_vec.len() == nodes_to_move.len(),
666      delete_group,
667    )
668    .await?;
669  }
670
671  Ok(DeleteHwResult {
672    nodes_moved: nodes_to_move,
673    target_nodes,
674    parent_nodes,
675  })
676}