manta_server/server/handlers/
session.rs

1//! CFS session handlers.
2//!
3//! - `GET    /api/v1/sessions`              → [`get_sessions`]
4//! - `POST   /api/v1/sessions`              → [`create_session`]
5//! - `DELETE /api/v1/sessions/{name}`       → [`delete_session`]
6//!   — with `?dry_run=true`, returns the deletion plan only.
7//! - `GET    /api/v1/sessions/{name}/logs`  → [`get_session_logs`] —
8//!   Server-Sent Events stream from the CFS session's pod log.
9//!
10//! All wrap `crate::service::session::*` and (for create) the
11//! backend `CfsTrait` for the actual CFS object. The log stream
12//! requires Vault for K8s creds plus the per-site `k8s_api_url`;
13//! when either is missing it returns 501.
14
15use std::convert::Infallible;
16
17use axum::{
18  Json,
19  extract::{Path, Query},
20  http::StatusCode,
21  response::{
22    IntoResponse,
23    sse::{Event, KeepAlive, Sse},
24  },
25};
26use futures::{AsyncBufReadExt, StreamExt};
27use manta_backend_dispatcher::types::{K8sAuth, K8sDetails};
28
29use super::{
30  ErrorResponse, RequestCtx, SiteHeader, require_k8s_url, require_vault,
31  serialize_or_500, to_handler_error, validate_repo_list_lengths,
32};
33use crate::service;
34
35// ---------------------------------------------------------------------------
36// GET /api/v1/sessions
37// ---------------------------------------------------------------------------
38
39pub use manta_shared::types::api::queries::{DeleteSessionQuery, SessionQuery};
40
41/// GET /sessions — list CFS sessions with optional filters.
42#[utoipa::path(get, path = "/sessions", tag = "sessions",
43  params(SessionQuery, SiteHeader),
44  security(("bearerAuth" = [])),
45  responses(
46    // CfsSessionGetResponse lives in manta-backend-dispatcher (third-party,
47    // no ToSchema) — kept as Value until upstream derives it.
48    (status = 200, description = "List of sessions", body = serde_json::Value),
49    (status = 400, description = "Bad request",      body = ErrorResponse),
50    (status = 401, description = "Unauthorized",     body = ErrorResponse),
51    (status = 500, description = "Internal error",   body = ErrorResponse),
52  )
53)]
54#[tracing::instrument(skip_all)]
55pub async fn get_sessions(
56  ctx: RequestCtx,
57  Query(q): Query<SessionQuery>,
58) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
59  let infra = ctx.infra();
60
61  let xnames: Vec<String> = q
62    .xnames
63    .map(|s| {
64      s.split(',')
65        .map(str::trim)
66        .filter(|v| !v.is_empty())
67        .map(str::to_string)
68        .collect()
69    })
70    .unwrap_or_default();
71
72  let params = service::session::GetSessionParams {
73    group: q.hsm_group,
74    xnames,
75    min_age: q.min_age,
76    max_age: q.max_age,
77    session_type: q.session_type,
78    status: q.status,
79    name: q.name,
80    limit: q.limit,
81  };
82
83  let sessions = service::session::get_sessions(&infra, &ctx.token, &params)
84    .await
85    .map_err(to_handler_error)?;
86
87  Ok(Json(sessions))
88}
89
90// ---------------------------------------------------------------------------
91// DELETE /api/v1/sessions/{name} — with ?dry_run=true support
92// ---------------------------------------------------------------------------
93
94/// DELETE /sessions/{name} — cancel and delete a CFS session; `?dry_run=true` previews.
95#[utoipa::path(delete, path = "/sessions/{name}", tag = "sessions",
96  params(("name" = String, Path, description = "Session name"), DeleteSessionQuery, SiteHeader),
97  security(("bearerAuth" = [])),
98  responses(
99    // dry_run/real result union — kept as Value until the union shape is formalised
100    (status = 200, description = "Session deleted or deletion preview", body = serde_json::Value),
101    (status = 401, description = "Unauthorized",                        body = ErrorResponse),
102    (status = 404, description = "Not found",                           body = ErrorResponse),
103    (status = 500, description = "Internal error",                      body = ErrorResponse),
104  )
105)]
106#[tracing::instrument(skip_all)]
107pub async fn delete_session(
108  ctx: RequestCtx,
109  Path(name): Path<String>,
110  Query(q): Query<DeleteSessionQuery>,
111) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
112  tracing::info!("delete_session name={} dry_run={}", name, q.dry_run);
113  let infra = ctx.infra();
114
115  let deletion_ctx =
116    service::session::prepare_session_deletion(&infra, &ctx.token, &name, None)
117      .await
118      .map_err(to_handler_error)?;
119
120  if q.dry_run {
121    return Ok((StatusCode::OK, Json(serialize_or_500(&deletion_ctx)?)));
122  }
123
124  service::session::execute_session_deletion(
125    &infra,
126    &ctx.token,
127    &deletion_ctx,
128    false,
129  )
130  .await
131  .map_err(to_handler_error)?;
132
133  Ok((StatusCode::OK, Json(serde_json::json!({ "deleted": name }))))
134}
135
136// ---------------------------------------------------------------------------
137// POST /api/v1/sessions — Create CFS session
138// ---------------------------------------------------------------------------
139
140pub use manta_shared::types::api::session::CreateSessionRequest;
141
142/// `POST /api/v1/sessions` — create a CFS session from one or more git repositories.
143#[utoipa::path(post, path = "/sessions", tag = "sessions",
144  params(SiteHeader),
145  request_body = CreateSessionRequest,
146  security(("bearerAuth" = [])),
147  responses(
148    (status = 201, description = "Session created",               body = manta_shared::types::api::responses::CreateSessionResponse),
149    (status = 400, description = "Bad request",                   body = ErrorResponse),
150    (status = 401, description = "Unauthorized",                  body = ErrorResponse),
151    (status = 500, description = "Internal error",                body = ErrorResponse),
152    (status = 501, description = "Vault not configured",          body = ErrorResponse),
153  )
154)]
155#[tracing::instrument(skip_all)]
156pub async fn create_session(
157  ctx: RequestCtx,
158  Json(body): Json<CreateSessionRequest>,
159) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
160  validate_repo_list_lengths(&body.repo_names, &body.repo_last_commit_ids)?;
161  tracing::info!("create_session repos={:?}", body.repo_names);
162  let infra = ctx.infra();
163
164  // Authorization: requested HSM group must be accessible to the token.
165  if let Some(ref hsm_group) = body.hsm_group {
166    service::authorization::validate_user_group_access(
167      &infra, &ctx.token, hsm_group,
168    )
169    .await
170    .map_err(to_handler_error)?;
171  }
172
173  // Authorization: every xname in ansible_limit must belong to a group
174  // the token can access.
175  if let Some(ref ansible_limit) = body.ansible_limit {
176    service::authorization::validate_ansible_limit_membership_access(
177      &infra,
178      &ctx.token,
179      ansible_limit,
180    )
181    .await
182    .map_err(to_handler_error)?;
183  }
184
185  let vault_base_url = require_vault(infra.vault_base_url)?;
186
187  let gitea_token =
188    crate::server::common::vault::http_client::get_shasta_vcs_token(
189      &ctx.token,
190      vault_base_url,
191      infra.site_name,
192    )
193    .await
194    .map_err(to_handler_error)?;
195
196  let repo_name_refs: Vec<&str> = body
197    .repo_names
198    .iter()
199    .map(std::string::String::as_str)
200    .collect();
201  let repo_commit_refs: Vec<&str> = body
202    .repo_last_commit_ids
203    .iter()
204    .map(std::string::String::as_str)
205    .collect();
206
207  let (session_name, config_name) = service::session::create_cfs_session(
208    &infra,
209    &ctx.token,
210    &gitea_token,
211    service::session::CreateCfsSessionParams {
212      cfs_conf_sess_name: body.cfs_conf_sess_name.as_deref(),
213      playbook_yaml_file_name: body.playbook_yaml_file_name.as_deref(),
214      group: body.hsm_group.as_deref(),
215      repo_names: &repo_name_refs,
216      repo_last_commit_ids: &repo_commit_refs,
217      ansible_limit: body.ansible_limit.as_deref(),
218      ansible_verbosity: body.ansible_verbosity.as_deref(),
219      ansible_passthrough: body.ansible_passthrough.as_deref(),
220    },
221  )
222  .await
223  .map_err(to_handler_error)?;
224
225  Ok((
226    StatusCode::CREATED,
227    Json(serde_json::json!({
228      "session_name": session_name,
229      "configuration_name": config_name,
230    })),
231  ))
232}
233
234// ---------------------------------------------------------------------------
235// GET /api/v1/sessions/{name}/logs — Stream CFS session logs via SSE
236// ---------------------------------------------------------------------------
237
238pub use manta_shared::types::api::queries::SessionLogsQuery;
239
240/// `GET /api/v1/sessions/{name}/logs` — stream CFS session pod logs via Server-Sent Events.
241#[utoipa::path(get, path = "/sessions/{name}/logs", tag = "sessions",
242  params(("name" = String, Path, description = "Session name"), SessionLogsQuery, SiteHeader),
243  security(("bearerAuth" = [])),
244  responses(
245    (status = 200, description = "SSE log stream"),
246    (status = 401, description = "Unauthorized",                   body = ErrorResponse),
247    (status = 500, description = "Internal error",                 body = ErrorResponse),
248    (status = 501, description = "Vault or k8s not configured",    body = ErrorResponse),
249  )
250)]
251#[tracing::instrument(skip_all)]
252pub async fn get_session_logs(
253  ctx: RequestCtx,
254  Path(name): Path<String>,
255  Query(q): Query<SessionLogsQuery>,
256) -> Result<
257  Sse<impl futures::Stream<Item = Result<Event, Infallible>>>,
258  (StatusCode, Json<ErrorResponse>),
259> {
260  let infra = ctx.infra();
261
262  let k8s_api_url = require_k8s_url(infra.k8s_api_url)?;
263  let vault_base_url = require_vault(infra.vault_base_url)?;
264
265  // Authorization: the caller's accessible groups must overlap the
266  // session's target.groups. Session logs frequently carry
267  // credentials, kernel-cmdline secrets, and ansible variable dumps;
268  // without this check any authenticated user could stream any
269  // session's logs.
270  service::session::validate_session_access(&infra, &ctx.token, &name)
271    .await
272    .map_err(to_handler_error)?;
273
274  let k8s = K8sDetails {
275    api_url: k8s_api_url.to_string(),
276    authentication: K8sAuth::Vault {
277      base_url: vault_base_url.to_string(),
278    },
279  };
280
281  let logs_stream = service::session::stream_logs(
282    &infra,
283    &ctx.token,
284    &name,
285    q.timestamps,
286    &k8s,
287  )
288  .await
289  .map_err(to_handler_error)?;
290
291  let sse_stream = logs_stream.lines().map(|result| {
292    Ok::<Event, Infallible>(
293      Event::default().data(result.unwrap_or_else(|e| format!("error: {e}"))),
294    )
295  });
296
297  Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
298}