manta_server/service/
migrate.rs

1//! Node migration between HSM groups.
2//!
3//! `backup` and `restore` are thin service wrappers so handlers can
4//! comply with the CLAUDE.md boundary rule (handlers → service →
5//! backend, never handlers → backend directly). Only `migrate_nodes`
6//! carries real orchestration (hosts-expression resolution,
7//! HSM-group curation, per-pair member migration).
8//!
9//! Migration is a (target × parent) fan-out: for every requested
10//! target group, every parent group the resolved xnames actually
11//! belong to is paired with it and the backend
12//! `migrate_group_members` call is issued. Nodes that don't live in
13//! one of the named parents are silently skipped, which keeps the
14//! call idempotent when a hosts expression is re-run.
15
16use std::collections::HashMap;
17
18use futures::future::join_all;
19use manta_backend_dispatcher::error::Error;
20use manta_backend_dispatcher::interfaces::hsm::group::GroupTrait;
21use manta_backend_dispatcher::interfaces::migrate_backup::MigrateBackupTrait;
22use manta_backend_dispatcher::interfaces::migrate_restore::MigrateRestoreTrait;
23
24use crate::server::common::app_context::InfraContext;
25use crate::service::authorization::validate_user_group_members_access;
26use crate::service::node_ops;
27
28/// Result of migrating nodes for a single parent→target pair.
29#[derive(serde::Serialize, utoipa::ToSchema)]
30pub struct NodeMigrationResult {
31  /// HSM group that received the nodes.
32  pub target_hsm_name: String,
33  /// HSM group that the nodes were moved out of.
34  pub parent_hsm_name: String,
35  /// Final member list of the target group after migration.
36  pub target_members: Vec<String>,
37  /// Remaining member list of the parent group after migration.
38  pub parent_members: Vec<String>,
39}
40
41/// Move the nodes resolved from `hosts_expression` out of any group
42/// in `parent_name_vec` and into every group in
43/// `target_group_name_vec`.
44///
45/// The xname set is curated through
46/// [`node_ops::get_curated_group_from_xname_hostlist`] and then
47/// filtered to the requested parents — nodes that don't currently
48/// belong to one of those parents are silently skipped, which keeps
49/// the call idempotent when the user passes the same expression
50/// twice. Each `target_name` is required to exist unless
51/// `create_group` is true (in dry-run mode the missing-group case
52/// is reported as a `BadRequest` so the operator sees what would have
53/// been created). Returns the moved xnames and one
54/// [`NodeMigrationResult`] per (target, parent) pair, with both
55/// membership lists sorted for stable rendering.
56///
57/// # Errors
58///
59/// - [`Error::BadRequest`] when the resolved xname list is empty, the
60///   caller lacks group access to one of the resolved xnames, or a
61///   dry-run would require creating a missing target group.
62/// - [`Error::NotFound`] when a target group is missing and
63///   `create_group` is false.
64/// - [`Error::NetError`] / [`Error::CsmError`] from any of the
65///   `get_group` / `migrate_group_members` backend calls.
66pub async fn migrate_nodes(
67  infra: &InfraContext<'_>,
68  token: &str,
69  target_group_name_vec: &[String],
70  parent_group_name_vec: &[String],
71  hosts_expression: &str,
72  dry_run: bool,
73  create_group: bool,
74) -> Result<(Vec<String>, Vec<NodeMigrationResult>), Error> {
75  let xname_to_move_vec = node_ops::from_user_hosts_expression_to_xname_vec(
76    infra,
77    token,
78    hosts_expression,
79    false,
80  )
81  .await?;
82
83  if xname_to_move_vec.is_empty() {
84    return Err(Error::BadRequest(
85      "The list of nodes to operate is empty. Nothing to do".to_string(),
86    ));
87  }
88
89  // Defence in depth: the handler already validates every named
90  // target/parent group, and the `retain` below filters out any
91  // resolved xname that isn't in a parent group the caller can
92  // reach — so the migration itself is bounded. We still gate on
93  // member access here so the resolved `xname_to_move_vec` returned
94  // in the response doesn't disclose nodes outside the caller's
95  // groups (the resolver runs against full cluster metadata).
96  validate_user_group_members_access(infra, token, &xname_to_move_vec).await?;
97
98  let mut group_summary: HashMap<String, Vec<String>> =
99    node_ops::get_curated_group_from_xname_hostlist(
100      infra,
101      token,
102      &xname_to_move_vec,
103    )
104    .await?;
105
106  group_summary.retain(|hsm_name, _| parent_group_name_vec.contains(hsm_name));
107
108  tracing::debug!("xnames to move: {:?}", xname_to_move_vec);
109
110  let mut results = Vec::new();
111
112  // Pre-fetch target-group existence in parallel — these are
113  // independent reads with no side effects on the backend.
114  let existence: Vec<bool> = join_all(
115    target_group_name_vec
116      .iter()
117      .map(|n| async { infra.backend.get_group(token, n).await.is_ok() }),
118  )
119  .await;
120
121  for (target_name, exists) in
122    target_group_name_vec.iter().zip(existence.iter())
123  {
124    if *exists {
125      tracing::debug!("The group '{target_name}' exists, good.");
126    } else if create_group {
127      tracing::info!(
128        "The group {} does not exist, it will be created",
129        target_name
130      );
131      if dry_run {
132        return Err(Error::BadRequest(format!(
133          "Dry-run selected, the group '{target_name}' created"
134        )));
135      }
136    } else {
137      return Err(Error::NotFound(format!(
138        "The group '{target_name}' does not exist and the option \
139                 to create the group was not specified"
140      )));
141    }
142
143    for (parent_group_name, xnames) in &group_summary {
144      let xnames_ref: Vec<&str> = xnames.iter().map(String::as_str).collect();
145      let (mut target_members, mut parent_members) = infra
146        .backend
147        .migrate_group_members(
148          token,
149          target_name,
150          parent_group_name,
151          &xnames_ref,
152          dry_run,
153        )
154        .await?;
155
156      target_members.sort();
157      parent_members.sort();
158
159      results.push(NodeMigrationResult {
160        target_hsm_name: target_name.clone(),
161        parent_hsm_name: parent_group_name.clone(),
162        target_members,
163        parent_members,
164      });
165    }
166  }
167
168  Ok((xname_to_move_vec, results))
169}
170
171/// Export BOS session templates (and related artifacts) to backup files.
172///
173/// Thin forwarder; authorization (admin-only) and filesystem path
174/// confinement are enforced by the caller before this function is
175/// invoked.
176pub async fn backup(
177  infra: &InfraContext<'_>,
178  token: &str,
179  bos: Option<&str>,
180  destination: Option<&str>,
181) -> Result<(), Error> {
182  infra.backend.migrate_backup(token, bos, destination).await
183}
184
185/// Restore BOS session templates and related artifacts from backup files.
186///
187/// Thin forwarder; authorization (admin-only) and filesystem path
188/// confinement are enforced by the caller before this function is
189/// invoked.
190#[allow(clippy::too_many_arguments)]
191pub async fn restore(
192  infra: &InfraContext<'_>,
193  token: &str,
194  bos_file: Option<&str>,
195  cfs_file: Option<&str>,
196  hsm_file: Option<&str>,
197  ims_file: Option<&str>,
198  image_dir: Option<&str>,
199  overwrite_group: bool,
200  overwrite_configuration: bool,
201  overwrite_image: bool,
202  overwrite_template: bool,
203) -> Result<(), Error> {
204  infra
205    .backend
206    .migrate_restore(
207      token,
208      bos_file,
209      cfs_file,
210      hsm_file,
211      ims_file,
212      image_dir,
213      overwrite_group,
214      overwrite_configuration,
215      overwrite_image,
216      overwrite_template,
217    )
218    .await
219}