manta_server/service/
session.rs

1//! CFS session queries, creation, deletion, and console-readiness
2//! validation.
3//!
4//! Session deletion follows the plan/apply pattern shared with
5//! [`crate::service::boot_parameters`] and
6//! [`crate::service::configuration`]:
7//! [`prepare_session_deletion`] collects everything the delete will
8//! need (the session itself, image ids it produced, CFS components,
9//! BSS boot parameters) without mutating state, and
10//! [`execute_session_deletion`] applies the plan. The two-step shape
11//! lets the CLI render a confirmation prompt with the full blast
12//! radius before any backend write.
13//!
14//! [`validate_session_access`] and [`validate_console_session`] are
15//! standalone pre-checks used by handlers that need to fail-fast
16//! before doing anything else (typically a console attach).
17
18use manta_backend_dispatcher::error::Error;
19use manta_backend_dispatcher::interfaces::apply_session::ApplySessionTrait;
20use manta_backend_dispatcher::interfaces::bss::BootParametersTrait;
21use manta_backend_dispatcher::interfaces::cfs::CfsTrait;
22use manta_backend_dispatcher::interfaces::hsm::group::GroupTrait;
23use manta_backend_dispatcher::types::Group;
24use manta_backend_dispatcher::types::bss::BootParameters;
25use manta_backend_dispatcher::types::cfs::component::Component;
26use manta_backend_dispatcher::types::cfs::session::CfsSessionGetResponse;
27
28use crate::server::common::app_context::InfraContext;
29use crate::service::authorization::validate_user_group_members_access;
30use crate::service::node_ops;
31pub use manta_shared::types::api::session::GetSessionParams;
32
33/// List CFS sessions visible to the caller, applying every filter on
34/// `params`.
35///
36/// The backend rejects mixing group and xname filters: an explicit
37/// `params.xnames` list wins and the group set is left empty;
38/// otherwise the request is scoped to `params.group` (single label)
39/// or to every group the token already grants access to. Group
40/// access and xname membership are validated before the backend
41/// call so the response can never leak rows the caller couldn't
42/// have listed directly.
43pub async fn get_sessions(
44  infra: &InfraContext<'_>,
45  token: &str,
46  params: &GetSessionParams,
47) -> Result<Vec<CfsSessionGetResponse>, Error> {
48  tracing::info!("Get CFS sessions");
49
50  // The backend rejects requests that pass both group names and
51  // xnames, so an explicit xname filter wins and skips the group
52  // expansion entirely — no group validation needed in that branch.
53  // Otherwise, one `get_group_available` call plus in-memory access
54  // validation replaces the prior two round-trips:
55  //   1. `get_group_available` to derive labels, then
56  //   2. `validate_user_group_vec_access` which internally called
57  //      `get_group_name_available` again for non-admin callers.
58  let target_group_vec: Vec<String> = if !params.xnames.is_empty() {
59    Vec::new()
60  } else {
61    let (_, labels) =
62      crate::service::group::resolve_target_and_available_groups(
63        infra,
64        token,
65        params.group.as_deref(),
66      )
67      .await?;
68    labels
69  };
70
71  validate_user_group_members_access(infra, token, &params.xnames).await?;
72
73  infra
74    .backend
75    .get_and_filter_sessions(
76      token,
77      target_group_vec,
78      params.xnames.iter().map(|xname| xname.as_ref()).collect(),
79      params.min_age.as_ref(),
80      params.max_age.as_ref(),
81      params.session_type.as_ref(),
82      params.status.as_ref(),
83      params.name.as_ref(),
84      params.limit.as_ref(),
85      None,
86    )
87    .await
88}
89
90/// Data needed to delete/cancel a session.
91#[derive(serde::Serialize)]
92pub struct SessionDeletionContext {
93  /// The session to be deleted.
94  pub session: CfsSessionGetResponse,
95  /// IMS image IDs produced by this session (empty for non-image sessions).
96  pub image_ids: Vec<String>,
97  /// All HSM groups the token has access to (used for membership checks).
98  pub group_available_vec: Vec<Group>,
99  /// CFS component states (used to clear desired-config references).
100  pub cfs_component_vec: Vec<Component>,
101  /// BSS boot parameters (used to unset boot image refs pointing at session images).
102  pub bss_bootparameters_vec: Vec<BootParameters>,
103}
104
105/// Collect everything a session-delete operation will need, without
106/// mutating any state.
107///
108/// Validates group access first, then fans out four backend calls in
109/// parallel (groups, sessions, CFS components, BSS boot parameters)
110/// because each is independent and the latency dominates the
111/// operation. Returns `NotFound` when the named session isn't in the
112/// (group-scoped) result set. The image ids the session produced are
113/// extracted up front so the apply step doesn't need to re-derive
114/// them.
115pub async fn prepare_session_deletion(
116  infra: &InfraContext<'_>,
117  token: &str,
118  session_name: &str,
119  settings_group_name_opt: Option<&str>,
120) -> Result<SessionDeletionContext, Error> {
121  // One backend fetch + in-memory validation, replacing the prior
122  // three round-trips. See `service::group::resolve_target_and_available_groups`.
123  let (group_available_vec, target_group_vec) =
124    crate::service::group::resolve_target_and_available_groups(
125      infra,
126      token,
127      settings_group_name_opt,
128    )
129    .await?;
130
131  tracing::info!("Fetching data from the backend...");
132  let start = std::time::Instant::now();
133
134  let (cfs_session_vec, cfs_component_vec, bss_bootparameters_vec) = tokio::try_join!(
135    infra.backend.get_and_filter_sessions(
136      token,
137      target_group_vec,
138      Vec::new(),
139      None,
140      None,
141      None,
142      None,
143      None,
144      None,
145      None,
146    ),
147    infra.backend.get_cfs_components(token, None, None, None),
148    infra.backend.get_all_bootparameters(token),
149  )?;
150
151  tracing::info!(
152    "Time elapsed to fetch information from backend: {:?}",
153    start.elapsed()
154  );
155
156  let session = cfs_session_vec
157    .into_iter()
158    .find(|s| s.name == session_name)
159    .ok_or_else(|| Error::NotFound(format!("CFS session '{session_name}'")))?;
160
161  let image_ids = session.get_result_id_vec();
162
163  Ok(SessionDeletionContext {
164    session,
165    image_ids,
166    group_available_vec,
167    cfs_component_vec,
168    bss_bootparameters_vec,
169  })
170}
171
172/// Apply a session delete previously planned by
173/// [`prepare_session_deletion`].
174///
175/// Delegates to the backend's combined delete/cancel routine, which
176/// also rewrites CFS component desired-config refs and unsets BSS
177/// boot-image refs that pointed at the session's images. With
178/// `dry_run = true` the routine returns the would-be changes without
179/// touching the backend.
180pub async fn execute_session_deletion(
181  infra: &InfraContext<'_>,
182  token: &str,
183  deletion_ctx: &SessionDeletionContext,
184  dry_run: bool,
185) -> Result<(), Error> {
186  infra
187    .backend
188    .delete_and_cancel_session(
189      token,
190      &deletion_ctx.group_available_vec,
191      &deletion_ctx.session,
192      &deletion_ctx.cfs_component_vec,
193      &deletion_ctx.bss_bootparameters_vec,
194      dry_run,
195    )
196    .await
197}
198
199/// Parameters for [`create_cfs_session`]. Bundled to keep the
200/// service entry point readable at the call site (the handler-level
201/// `CreateSessionRequest` body folds 1:1 into this).
202pub struct CreateCfsSessionParams<'a> {
203  /// Optional caller-supplied session name; backend autogenerates one
204  /// when absent.
205  pub cfs_conf_sess_name: Option<&'a str>,
206  /// Optional playbook path inside the rendered configuration.
207  pub playbook_yaml_file_name: Option<&'a str>,
208  /// HSM group the session targets when no `ansible_limit` is given.
209  pub group: Option<&'a str>,
210  /// VCS repository names mirroring `repo_last_commit_ids`.
211  pub repo_names: &'a [&'a str],
212  /// Commit SHAs, one per `repo_names` entry.
213  pub repo_last_commit_ids: &'a [&'a str],
214  /// Hosts expression (xnames / NIDs / hostlist) limiting the session;
215  /// resolved to xnames before the CFS request.
216  pub ansible_limit: Option<&'a str>,
217  /// Ansible verbosity flag (`-v` .. `-vvv`).
218  pub ansible_verbosity: Option<&'a str>,
219  /// Arbitrary args forwarded to `ansible-playbook`.
220  pub ansible_passthrough: Option<&'a str>,
221}
222
223/// Create a CFS session, expanding the ansible-limit hosts expression
224/// to xnames first.
225///
226/// `params.ansible_limit` is parsed as a hostlist / NID / xname
227/// expression the same way other entry points do, then joined with
228/// commas for the CFS request — CFS itself is happy with either form
229/// but downstream tooling expects xnames. When `params.ansible_limit`
230/// is `None`, the session targets the full group selected by
231/// `params.group`. Returns
232/// `(cfs_configuration_name, cfs_session_name)`.
233pub async fn create_cfs_session(
234  infra: &InfraContext<'_>,
235  token: &str,
236  gitea_token: &str,
237  params: CreateCfsSessionParams<'_>,
238) -> Result<(String, String), Error> {
239  let ansible_limit = if let Some(ansible_limit) = params.ansible_limit {
240    let xname_vec = node_ops::from_user_hosts_expression_to_xname_vec(
241      infra,
242      token,
243      ansible_limit,
244      false,
245    )
246    .await?;
247    Some(xname_vec.join(","))
248  } else {
249    None
250  };
251
252  infra
253    .backend
254    .apply_session(
255      gitea_token,
256      infra.gitea_base_url,
257      token,
258      params.cfs_conf_sess_name,
259      params.playbook_yaml_file_name,
260      params.group,
261      params.repo_names,
262      params.repo_last_commit_ids,
263      ansible_limit.as_deref(),
264      params.ansible_verbosity,
265      params.ansible_passthrough,
266    )
267    .await
268}
269
270/// Fetch a single CFS session by name.
271///
272/// Returns `NotFound` when no session with that name exists.
273async fn fetch_session_by_name(
274  infra: &InfraContext<'_>,
275  token: &str,
276  name: &str,
277) -> Result<CfsSessionGetResponse, Error> {
278  let sessions = infra
279    .backend
280    .get_and_filter_sessions(
281      token,
282      Vec::new(),
283      Vec::new(),
284      None,
285      None,
286      None,
287      None,
288      Some(&name.to_string()),
289      None,
290      None,
291    )
292    .await?;
293
294  sessions
295    .into_iter()
296    .next()
297    .ok_or_else(|| Error::NotFound(format!("CFS session '{name}'")))
298}
299
300/// Fetch a session by name and validate that the caller is allowed
301/// to act on it.
302///
303/// Access is granted when every HSM group named in the session's
304/// `target.groups` overlaps the caller's accessible groups (the union
305/// returned by `InfraContext::get_group_name_available`). A session
306/// that targets no HSM groups (e.g. a runtime session) is treated as
307/// not gated by group access.
308///
309/// Returns the fetched session so the caller doesn't double-GET.
310/// `NotFound` when the session doesn't exist; `BadRequest` when any
311/// target group is outside the accessible set — matching the
312/// access-denial shape used by
313/// [`crate::service::authorization::validate_user_group_access`].
314pub async fn validate_session_access(
315  infra: &InfraContext<'_>,
316  token: &str,
317  session_name: &str,
318) -> Result<CfsSessionGetResponse, Error> {
319  let session = fetch_session_by_name(infra, token, session_name).await?;
320
321  let target_groups = session.get_target_hsm().unwrap_or_default();
322  if !target_groups.is_empty() {
323    let accessible = infra.backend.get_group_name_available(token).await?;
324    if let Some(unauthorized) =
325      target_groups.iter().find(|g| !accessible.contains(g))
326    {
327      return Err(Error::BadRequest(format!(
328        "Can't access CFS session '{session_name}': it targets HSM \
329         group '{unauthorized}' which is not in your accessible set"
330      )));
331    }
332  }
333
334  Ok(session)
335}
336
337/// Reject sessions that didn't produce a result image.
338///
339/// `BadRequest` when the session has no `result_id` — callers
340/// shouldn't try to PATCH a non-existent image. csm-rs's deeper check
341/// inside `collect_and_stamp_image` remains as a defence-in-depth
342/// safety net.
343pub fn require_result_image(
344  session: &CfsSessionGetResponse,
345) -> Result<(), Error> {
346  if session.get_first_result_id().is_none() {
347    return Err(Error::BadRequest(format!(
348      "CFS session '{}' produced no image (no result_id); refusing to stamp",
349      session.name
350    )));
351  }
352  Ok(())
353}
354
355/// Tail the Ansible-container log for `session_name` via a buffered reader.
356///
357/// Thin forwarder from the handler into the backend — handlers must not
358/// call `infra.backend.get_session_logs_stream` directly per the
359/// CLAUDE.md boundary rule. Session-access validation must happen
360/// BEFORE this call via [`validate_session_access`].
361pub async fn stream_logs(
362  infra: &InfraContext<'_>,
363  token: &str,
364  session_name: &str,
365  timestamps: bool,
366  k8s: &manta_backend_dispatcher::types::K8sDetails,
367) -> Result<std::pin::Pin<Box<dyn futures::AsyncBufRead + Send>>, Error> {
368  infra
369    .backend
370    .get_session_logs_stream(
371      token,
372      infra.site_name,
373      session_name,
374      timestamps,
375      k8s,
376    )
377    .await
378}
379
380/// Validate that a CFS session is suitable for attaching a console.
381///
382/// Returns `NotFound` if the session doesn't exist, `BadRequest` if the
383/// session is not image-type or has missing internal state, and `Conflict`
384/// if it is not running.
385pub async fn validate_console_session(
386  infra: &InfraContext<'_>,
387  token: &str,
388  name: &str,
389) -> Result<(), Error> {
390  let session = fetch_session_by_name(infra, token, name).await?;
391
392  let target_def = session
393    .target
394    .as_ref()
395    .and_then(|t| t.definition.as_ref())
396    .ok_or_else(|| {
397      Error::BadRequest(format!(
398        "CFS session '{name}' has no target definition"
399      ))
400    })?;
401
402  if target_def != "image" {
403    return Err(Error::BadRequest(format!(
404      "CFS session '{name}' is not an image-type session (got '{target_def}')"
405    )));
406  }
407
408  let status = session
409    .status
410    .as_ref()
411    .and_then(|s| s.session.as_ref())
412    .and_then(|s| s.status.as_ref())
413    .ok_or_else(|| {
414      Error::BadRequest(format!("CFS session '{name}' has no status"))
415    })?;
416
417  if status != "running" {
418    return Err(Error::Conflict(format!(
419      "CFS session '{name}' is not running (status: '{status}')"
420    )));
421  }
422
423  Ok(())
424}
425
426#[cfg(test)]
427mod tests {
428  //! Function-level tests for the boundary-check helpers. The
429  //! `InfraContext`-touching helpers (`validate_session_access`,
430  //! `get_sessions`, etc.) are exercised through integration tests
431  //! against `router()` — see `crates/manta-server/tests/`.
432
433  use super::{Error, require_result_image};
434  use manta_backend_dispatcher::types::cfs::session::{
435    Artifact, CfsSessionGetResponse, Status,
436  };
437
438  fn session_with_result_id(
439    name: &str,
440    result_id: Option<&str>,
441  ) -> CfsSessionGetResponse {
442    CfsSessionGetResponse {
443      name: name.to_string(),
444      configuration: None,
445      ansible: None,
446      target: None,
447      status: Some(Status {
448        artifacts: Some(vec![Artifact {
449          image_id: None,
450          result_id: result_id.map(str::to_string),
451          r#type: None,
452        }]),
453        session: None,
454      }),
455      tags: None,
456      debug_on_failure: false,
457      logs: None,
458    }
459  }
460
461  #[test]
462  fn require_result_image_accepts_session_with_result_id() {
463    let session = session_with_result_id("sat-img-v1", Some("ims-image-abc"));
464    assert!(require_result_image(&session).is_ok());
465  }
466
467  #[test]
468  fn require_result_image_rejects_session_without_result_id() {
469    let session = session_with_result_id("sat-img-v1", None);
470    let err = require_result_image(&session).unwrap_err();
471    assert!(
472      matches!(err, Error::BadRequest(_)),
473      "expected BadRequest, got {err:?}"
474    );
475    assert!(err.to_string().contains("sat-img-v1"));
476    assert!(err.to_string().contains("no result_id"));
477  }
478
479  #[test]
480  fn require_result_image_rejects_session_with_no_artifacts() {
481    let session = CfsSessionGetResponse {
482      name: "sat-img-v1".to_string(),
483      configuration: None,
484      ansible: None,
485      target: None,
486      status: None,
487      tags: None,
488      debug_on_failure: false,
489      logs: None,
490    };
491    let err = require_result_image(&session).unwrap_err();
492    assert!(matches!(err, Error::BadRequest(_)));
493  }
494}