Skip to main content

manta_server/server/handlers/
session.rs

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