1use 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
33pub 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 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, ¶ms.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#[derive(serde::Serialize)]
92pub struct SessionDeletionContext {
93 pub session: CfsSessionGetResponse,
95 pub image_ids: Vec<String>,
97 pub group_available_vec: Vec<Group>,
99 pub cfs_component_vec: Vec<Component>,
101 pub bss_bootparameters_vec: Vec<BootParameters>,
103}
104
105pub 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 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
172pub 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
199pub struct CreateCfsSessionParams<'a> {
203 pub cfs_conf_sess_name: Option<&'a str>,
206 pub playbook_yaml_file_name: Option<&'a str>,
208 pub group: Option<&'a str>,
210 pub repo_names: &'a [&'a str],
212 pub repo_last_commit_ids: &'a [&'a str],
214 pub ansible_limit: Option<&'a str>,
217 pub ansible_verbosity: Option<&'a str>,
219 pub ansible_passthrough: Option<&'a str>,
221}
222
223pub 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
270async 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
300pub 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
337pub 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
355pub 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
380pub 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 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}