manta_server/service/
session.rs

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