manta_server/server/handlers/
hw_cluster.rs

1//! Hardware cluster add/delete/configuration handlers.
2
3use axum::{Json, extract::Path, http::StatusCode, response::IntoResponse};
4use serde::Deserialize;
5use utoipa::ToSchema;
6
7use super::{
8  ErrorResponse, RequestCtx, SiteHeader, default_true, display_error,
9  to_handler_error,
10};
11use crate::service;
12
13// ---------------------------------------------------------------------------
14// POST /api/v1/hardware-clusters/{target}/members
15// ---------------------------------------------------------------------------
16
17/// Request body for `POST /hardware-clusters/{target}/members`.
18#[derive(Deserialize, ToSchema)]
19pub struct AddHwComponentRequest {
20  /// Source HSM group that donates nodes matching `pattern`.
21  pub parent_cluster: String,
22  /// Hardware component pattern used to select which nodes to move.
23  pub pattern: String,
24  /// Create the target HSM group if it does not already exist.
25  #[serde(default)]
26  pub create_hsm_group: bool,
27  /// When true, returns the planned changes without modifying group membership.
28  #[serde(default)]
29  pub dry_run: bool,
30}
31
32/// `POST /api/v1/hardware-clusters/{target}/members` — move nodes matching a hardware pattern into a cluster.
33#[utoipa::path(post, path = "/hardware-clusters/{target}/members", tag = "hardware",
34  params(("target" = String, Path, description = "Target cluster name"), SiteHeader),
35  request_body = AddHwComponentRequest,
36  security(("bearerAuth" = [])),
37  responses(
38    (status = 200, description = "Members added or preview", body = serde_json::Value),
39    (status = 401, description = "Unauthorized",             body = ErrorResponse),
40    (status = 500, description = "Internal error",           body = ErrorResponse),
41  )
42)]
43#[tracing::instrument(skip_all)]
44pub async fn add_hw_component(
45  ctx: RequestCtx,
46  Path(target): Path<String>,
47  Json(body): Json<AddHwComponentRequest>,
48) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
49  tracing::info!(
50    "add_hw_component target={} parent={} dry_run={}",
51    target,
52    body.parent_cluster,
53    body.dry_run
54  );
55  let infra = ctx.infra();
56
57  service::group::validate_hsm_group_access(&infra, &ctx.token, &target)
58    .await
59    .map_err(to_handler_error)?;
60  service::group::validate_hsm_group_access(
61    &infra,
62    &ctx.token,
63    &body.parent_cluster,
64  )
65  .await
66  .map_err(to_handler_error)?;
67
68  let result = crate::service::hw_cluster::add_hw_component(
69    infra.backend,
70    &ctx.token,
71    &target,
72    &body.parent_cluster,
73    &body.pattern,
74    body.dry_run,
75    body.create_hsm_group,
76  )
77  .await
78  .map_err(display_error)?;
79
80  Ok(Json(serde_json::json!({
81    "dry_run": body.dry_run,
82    "nodes_moved": result.nodes_moved,
83    "target_cluster": target,
84    "target_nodes": result.target_nodes,
85    "parent_cluster": body.parent_cluster,
86    "parent_nodes": result.parent_nodes,
87  })))
88}
89
90// ---------------------------------------------------------------------------
91// DELETE /api/v1/hardware-clusters/{target}/members
92// ---------------------------------------------------------------------------
93
94/// Request body for `DELETE /hardware-clusters/{target}/members`.
95#[derive(Deserialize, ToSchema)]
96pub struct DeleteHwComponentRequest {
97  /// Destination HSM group that receives nodes moved out of the target cluster.
98  pub parent_cluster: String,
99  /// Hardware component pattern used to select which nodes to move back.
100  pub pattern: String,
101  /// Delete the target HSM group if it becomes empty after the operation.
102  #[serde(default)]
103  pub delete_hsm_group: bool,
104  /// When true, returns the planned changes without modifying group membership.
105  #[serde(default)]
106  pub dry_run: bool,
107}
108
109/// `DELETE /api/v1/hardware-clusters/{target}/members` — move nodes back to parent cluster by hardware pattern.
110#[utoipa::path(delete, path = "/hardware-clusters/{target}/members", tag = "hardware",
111  params(("target" = String, Path, description = "Target cluster name"), SiteHeader),
112  request_body = DeleteHwComponentRequest,
113  security(("bearerAuth" = [])),
114  responses(
115    (status = 200, description = "Members removed or preview", body = serde_json::Value),
116    (status = 401, description = "Unauthorized",               body = ErrorResponse),
117    (status = 500, description = "Internal error",             body = ErrorResponse),
118  )
119)]
120#[tracing::instrument(skip_all)]
121pub async fn delete_hw_component(
122  ctx: RequestCtx,
123  Path(target): Path<String>,
124  Json(body): Json<DeleteHwComponentRequest>,
125) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
126  tracing::info!(
127    "delete_hw_component target={} parent={} dry_run={}",
128    target,
129    body.parent_cluster,
130    body.dry_run
131  );
132  let infra = ctx.infra();
133
134  service::group::validate_hsm_group_access(&infra, &ctx.token, &target)
135    .await
136    .map_err(to_handler_error)?;
137  service::group::validate_hsm_group_access(
138    &infra,
139    &ctx.token,
140    &body.parent_cluster,
141  )
142  .await
143  .map_err(to_handler_error)?;
144
145  let result = crate::service::hw_cluster::delete_hw_component(
146    infra.backend,
147    &ctx.token,
148    &target,
149    &body.parent_cluster,
150    &body.pattern,
151    body.dry_run,
152    body.delete_hsm_group,
153  )
154  .await
155  .map_err(display_error)?;
156
157  Ok(Json(serde_json::json!({
158    "dry_run": body.dry_run,
159    "nodes_moved": result.nodes_moved,
160    "target_cluster": target,
161    "target_nodes": result.target_nodes,
162    "parent_cluster": body.parent_cluster,
163    "parent_nodes": result.parent_nodes,
164  })))
165}
166
167// ---------------------------------------------------------------------------
168// POST /api/v1/hardware-clusters/{target}/configuration
169// ---------------------------------------------------------------------------
170
171/// Whether to pin nodes to the target cluster or unpin them back to the parent.
172#[derive(Debug, Deserialize, Default, ToSchema)]
173#[serde(rename_all = "lowercase")]
174pub enum HwClusterMode {
175  /// Move nodes from the parent cluster into the target cluster.
176  #[default]
177  Pin,
178  /// Move nodes back from the target cluster to the parent cluster.
179  Unpin,
180}
181
182/// Request body for `POST /hardware-clusters/{target}/configuration`.
183#[derive(Deserialize, ToSchema)]
184pub struct ApplyHwConfigurationRequest {
185  /// Source (parent) HSM group supplying nodes.
186  pub parent_cluster: String,
187  /// Hardware component pattern selecting which nodes to pin/unpin.
188  pub pattern: String,
189  /// Whether to pin nodes into the target cluster or unpin back to parent (default: pin).
190  #[serde(default)]
191  pub mode: HwClusterMode,
192  /// Create the target HSM group if absent (default true).
193  #[serde(default = "default_true")]
194  pub create_target_hsm_group: bool,
195  /// Delete the parent HSM group if it becomes empty (default true).
196  #[serde(default = "default_true")]
197  pub delete_empty_parent_hsm_group: bool,
198  /// When true, returns the planned changes without modifying group membership.
199  #[serde(default)]
200  pub dry_run: bool,
201}
202
203/// `POST /api/v1/hardware-clusters/{target}/configuration` — pin or unpin nodes between clusters by hardware pattern.
204#[utoipa::path(post, path = "/hardware-clusters/{target}/configuration", tag = "hardware",
205  params(("target" = String, Path, description = "Target cluster name"), SiteHeader),
206  request_body = ApplyHwConfigurationRequest,
207  security(("bearerAuth" = [])),
208  responses(
209    (status = 200, description = "Configuration applied or preview", body = serde_json::Value),
210    (status = 401, description = "Unauthorized",                     body = ErrorResponse),
211    (status = 500, description = "Internal error",                   body = ErrorResponse),
212  )
213)]
214#[tracing::instrument(skip_all)]
215pub async fn apply_hw_configuration(
216  ctx: RequestCtx,
217  Path(target): Path<String>,
218  Json(body): Json<ApplyHwConfigurationRequest>,
219) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
220  tracing::info!(
221    "apply_hw_configuration target={} parent={} dry_run={}",
222    target,
223    body.parent_cluster,
224    body.dry_run
225  );
226  let infra = ctx.infra();
227
228  service::group::validate_hsm_group_access(&infra, &ctx.token, &target)
229    .await
230    .map_err(to_handler_error)?;
231  service::group::validate_hsm_group_access(
232    &infra,
233    &ctx.token,
234    &body.parent_cluster,
235  )
236  .await
237  .map_err(to_handler_error)?;
238
239  let mode = match body.mode {
240    HwClusterMode::Pin => crate::service::hw_cluster::HwClusterMode::Pin,
241    HwClusterMode::Unpin => crate::service::hw_cluster::HwClusterMode::Unpin,
242  };
243
244  let result = crate::service::hw_cluster::apply_hw_configuration(
245    infra.backend,
246    mode,
247    &ctx.token,
248    &target,
249    &body.parent_cluster,
250    &body.pattern,
251    body.dry_run,
252    body.create_target_hsm_group,
253    body.delete_empty_parent_hsm_group,
254  )
255  .await
256  .map_err(display_error)?;
257
258  Ok(Json(serde_json::json!({
259    "dry_run": body.dry_run,
260    "target_cluster": target,
261    "target_nodes": result.target_nodes,
262    "parent_cluster": body.parent_cluster,
263    "parent_nodes": result.parent_nodes,
264  })))
265}