manta_server/server/handlers/
group.rs

1//! HSM group CRUD + membership handlers.
2
3use axum::{
4  Json,
5  extract::{Path, Query},
6  http::StatusCode,
7  response::IntoResponse,
8};
9use manta_backend_dispatcher::interfaces::hsm::group::GroupTrait;
10use serde::{Deserialize, Serialize};
11use utoipa::{IntoParams, ToSchema};
12
13use super::{ErrorResponse, RequestCtx, SiteHeader, to_handler_error};
14use crate::service;
15
16// ---------------------------------------------------------------------------
17// GET /api/v1/groups
18// ---------------------------------------------------------------------------
19
20/// Query parameters for `GET /groups`.
21#[derive(Deserialize, IntoParams)]
22pub struct GroupQuery {
23  /// Exact group name; returns all groups when `None`.
24  pub name: Option<String>,
25}
26
27/// GET /groups/available — list HSM group names the token can access.
28///
29/// Backs CLI authorization helpers that used to call
30/// `backend.get_group_name_available` directly.
31#[utoipa::path(get, path = "/groups/available", tag = "groups",
32  params(SiteHeader),
33  security(("bearerAuth" = [])),
34  responses(
35    (status = 200, description = "List of accessible group names", body = Vec<String>),
36    (status = 401, description = "Unauthorized",                   body = ErrorResponse),
37    (status = 500, description = "Internal error",                 body = ErrorResponse),
38  )
39)]
40#[tracing::instrument(skip_all)]
41pub async fn get_available_groups(
42  ctx: RequestCtx,
43) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
44  let infra = ctx.infra();
45  let names = service::group::get_available_group_names(&infra, &ctx.token)
46    .await
47    .map_err(to_handler_error)?;
48  Ok(Json(names))
49}
50
51/// GET /groups/all — list every HSM group in the system.
52///
53/// Backs CLI commands (e.g. `config_set_hsm_common`) that need the full
54/// catalogue, not just the accessible-to-this-token subset.
55#[utoipa::path(get, path = "/groups/all", tag = "groups",
56  params(SiteHeader),
57  security(("bearerAuth" = [])),
58  responses(
59    (status = 200, description = "List of all groups",      body = serde_json::Value),
60    (status = 401, description = "Unauthorized",            body = ErrorResponse),
61    (status = 500, description = "Internal error",          body = ErrorResponse),
62  )
63)]
64#[tracing::instrument(skip_all)]
65pub async fn get_all_groups(
66  ctx: RequestCtx,
67) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
68  let infra = ctx.infra();
69  let groups = service::group::get_all_groups(&infra, &ctx.token)
70    .await
71    .map_err(to_handler_error)?;
72  Ok(Json(groups))
73}
74
75/// GET /groups — list HSM groups, optionally filtered by name.
76#[utoipa::path(get, path = "/groups", tag = "groups",
77  params(GroupQuery, SiteHeader),
78  security(("bearerAuth" = [])),
79  responses(
80    (status = 200, description = "List of groups", body = serde_json::Value),
81    (status = 401, description = "Unauthorized",   body = ErrorResponse),
82    (status = 500, description = "Internal error", body = ErrorResponse),
83  )
84)]
85#[tracing::instrument(skip_all)]
86pub async fn get_groups(
87  ctx: RequestCtx,
88  Query(q): Query<GroupQuery>,
89) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
90  let infra = ctx.infra();
91
92  let params = service::group::GetGroupParams {
93    group_name: q.name,
94    settings_hsm_group_name: None,
95  };
96
97  let groups = service::group::get_groups(&infra, &ctx.token, &params)
98    .await
99    .map_err(to_handler_error)?;
100
101  Ok(Json(groups))
102}
103
104// ---------------------------------------------------------------------------
105// DELETE /api/v1/groups/{label}
106// ---------------------------------------------------------------------------
107
108/// Query parameters for `DELETE /groups/{label}`.
109#[derive(Deserialize, IntoParams)]
110pub struct DeleteGroupQuery {
111  /// Delete even if the group still has members (default: false).
112  #[serde(default)]
113  pub force: bool,
114}
115
116/// DELETE /groups/{label} — remove an HSM group.
117#[utoipa::path(delete, path = "/groups/{label}", tag = "groups",
118  params(("label" = String, Path, description = "Group label"), DeleteGroupQuery, SiteHeader),
119  security(("bearerAuth" = [])),
120  responses(
121    (status = 204, description = "Group removed"),
122    (status = 401, description = "Unauthorized",   body = ErrorResponse),
123    (status = 404, description = "Not found",      body = ErrorResponse),
124    (status = 500, description = "Internal error", body = ErrorResponse),
125  )
126)]
127#[tracing::instrument(skip_all)]
128pub async fn delete_group(
129  ctx: RequestCtx,
130  Path(label): Path<String>,
131  Query(q): Query<DeleteGroupQuery>,
132) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
133  tracing::info!("delete_group label={} force={}", label, q.force);
134  let infra = ctx.infra();
135
136  service::group::delete_group(&infra, &ctx.token, &label, q.force)
137    .await
138    .map_err(to_handler_error)?;
139
140  Ok(StatusCode::NO_CONTENT)
141}
142
143// ---------------------------------------------------------------------------
144// POST /api/v1/groups
145// ---------------------------------------------------------------------------
146
147/// POST /groups — create a new HSM group.
148#[utoipa::path(post, path = "/groups", tag = "groups",
149  params(SiteHeader),
150  request_body = manta_backend_dispatcher::types::Group,
151  security(("bearerAuth" = [])),
152  responses(
153    (status = 201, description = "Group created",    body = serde_json::Value),
154    (status = 401, description = "Unauthorized",     body = ErrorResponse),
155    (status = 409, description = "Conflict",         body = ErrorResponse),
156    (status = 500, description = "Internal error",   body = ErrorResponse),
157  )
158)]
159#[tracing::instrument(skip_all)]
160pub async fn create_group(
161  ctx: RequestCtx,
162  Json(group): Json<::manta_backend_dispatcher::types::Group>,
163) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
164  tracing::info!("create_group");
165  let infra = ctx.infra();
166
167  service::group::create_group(&infra, &ctx.token, group)
168    .await
169    .map_err(to_handler_error)?;
170
171  Ok((
172    StatusCode::CREATED,
173    Json(serde_json::json!({ "created": true })),
174  ))
175}
176
177// ---------------------------------------------------------------------------
178// POST /api/v1/groups/{name}/members
179// ---------------------------------------------------------------------------
180
181/// Body for `POST /groups/{name}/members`.
182#[derive(Deserialize, ToSchema)]
183pub struct AddNodesToGroupRequest {
184  /// Hostlist expression (xnames, NIDs, or hostlist notation)
185  /// identifying the new member set for the group.
186  pub hosts_expression: String,
187}
188
189/// Response for `POST /groups/{name}/members`.
190#[derive(Serialize, ToSchema)]
191pub struct AddNodesToGroupResponse {
192  /// Xnames that were added to the group as part of this request.
193  pub added: Vec<String>,
194  /// Xnames that were removed from the group as part of this request.
195  pub removed: Vec<String>,
196}
197
198/// POST /groups/{name}/members — replace a group's member list from a host expression.
199#[utoipa::path(post, path = "/groups/{name}/members", tag = "groups",
200  params(("name" = String, Path, description = "Group name"), SiteHeader),
201  request_body = AddNodesToGroupRequest,
202  security(("bearerAuth" = [])),
203  responses(
204    (status = 200, description = "Members updated",   body = AddNodesToGroupResponse),
205    (status = 400, description = "Bad request",       body = ErrorResponse),
206    (status = 401, description = "Unauthorized",      body = ErrorResponse),
207    (status = 500, description = "Internal error",    body = ErrorResponse),
208  )
209)]
210#[tracing::instrument(skip_all)]
211pub async fn add_nodes_to_group(
212  ctx: RequestCtx,
213  Path(name): Path<String>,
214  Json(body): Json<AddNodesToGroupRequest>,
215) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
216  tracing::info!(
217    "add_nodes_to_group group={} hosts={}",
218    name,
219    body.hosts_expression
220  );
221  let infra = ctx.infra();
222
223  let (added, removed) = service::group::add_nodes_to_group(
224    &infra,
225    &ctx.token,
226    &name,
227    &body.hosts_expression,
228  )
229  .await
230  .map_err(to_handler_error)?;
231
232  Ok(Json(AddNodesToGroupResponse { added, removed }))
233}
234
235// ---------------------------------------------------------------------------
236// DELETE /api/v1/groups/{name}/members — Remove nodes from HSM group
237// ---------------------------------------------------------------------------
238
239/// Request body for `DELETE /groups/{name}/members`.
240#[derive(Deserialize, ToSchema)]
241pub struct DeleteGroupMembersRequest {
242  /// Hosts expression (xnames, nids, or hostlist notation) identifying nodes to remove.
243  pub xnames_expression: String,
244  /// When true, validates the request without modifying group membership.
245  #[serde(default)]
246  pub dry_run: bool,
247}
248
249/// `DELETE /api/v1/groups/{name}/members` — remove nodes from an HSM group.
250#[utoipa::path(delete, path = "/groups/{name}/members", tag = "groups",
251  params(("name" = String, Path, description = "Group name"), SiteHeader),
252  request_body = DeleteGroupMembersRequest,
253  security(("bearerAuth" = [])),
254  responses(
255    (status = 204, description = "Members removed"),
256    (status = 400, description = "Bad request",      body = ErrorResponse),
257    (status = 401, description = "Unauthorized",     body = ErrorResponse),
258    (status = 500, description = "Internal error",   body = ErrorResponse),
259  )
260)]
261#[tracing::instrument(skip_all)]
262pub async fn delete_group_members(
263  ctx: RequestCtx,
264  Path(name): Path<String>,
265  Json(body): Json<DeleteGroupMembersRequest>,
266) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
267  tracing::info!(
268    "delete_group_members group={} xnames_expression={} dry_run={}",
269    name,
270    body.xnames_expression,
271    body.dry_run
272  );
273  let infra = ctx.infra();
274
275  let xnames = crate::server::common::node_ops::resolve_hosts_expression(
276    infra.backend,
277    &ctx.token,
278    &body.xnames_expression,
279    false,
280  )
281  .await
282  .map_err(to_handler_error)?;
283
284  if !body.dry_run {
285    for xname in &xnames {
286      infra
287        .backend
288        .delete_member_from_group(&ctx.token, &name, xname)
289        .await
290        .map_err(to_handler_error)?;
291    }
292  }
293
294  Ok(StatusCode::NO_CONTENT)
295}