1use std::cmp::Reverse;
20
21use chrono::NaiveDateTime;
22use manta_backend_dispatcher::error::Error;
23use manta_backend_dispatcher::interfaces::bss::BootParametersTrait;
24use manta_backend_dispatcher::interfaces::ims::ImsTrait;
25use manta_backend_dispatcher::types::Group;
26use manta_backend_dispatcher::types::bss::BootParameters;
27use manta_backend_dispatcher::types::ims::Image;
28use manta_shared::common::parse_ims_timestamp;
29
30use crate::server::common::app_context::InfraContext;
31use crate::service::boot_parameters::get_restricted_boot_parameters;
32use crate::service::configuration::validate_date_range;
33pub use manta_shared::types::api::image::GetImagesParams;
34
35pub async fn get_images(
52 infra: &InfraContext<'_>,
53 token: &str,
54 params: &GetImagesParams,
55) -> Result<Vec<Image>, Error> {
56 validate_date_range(params.since, params.until)?;
57
58 let mut image_vec = infra
59 .backend
60 .get_images(token, params.id.as_deref())
61 .await?;
62
63 image_vec = apply_pattern_filter(image_vec, params.pattern.as_deref())?;
64 image_vec = apply_date_filter(image_vec, params.since, params.until);
65
66 Ok(sort_and_cap(image_vec, params.limit))
67}
68
69fn apply_date_filter(
90 image_vec: Vec<Image>,
91 since: Option<NaiveDateTime>,
92 until: Option<NaiveDateTime>,
93) -> Vec<Image> {
94 if since.is_none() && until.is_none() {
95 return image_vec;
96 }
97
98 image_vec
99 .into_iter()
100 .filter(|img| {
101 let Some(created) = img.created.as_deref().and_then(parse_ims_timestamp)
102 else {
103 return false;
104 };
105 since.is_none_or(|s| created >= s) && until.is_none_or(|u| created <= u)
106 })
107 .collect()
108}
109
110fn sort_and_cap(mut image_vec: Vec<Image>, limit: Option<u8>) -> Vec<Image> {
139 image_vec.sort_by_cached_key(|image| {
140 Reverse(image.created.as_deref().and_then(parse_ims_timestamp))
141 });
142
143 if let Some(limit) = limit {
144 image_vec.truncate(limit as usize);
145 }
146
147 image_vec.reverse();
148
149 image_vec
150}
151
152fn apply_pattern_filter(
157 image_vec: Vec<Image>,
158 pattern: Option<&str>,
159) -> Result<Vec<Image>, Error> {
160 let Some(pattern) = pattern else {
161 return Ok(image_vec);
162 };
163 let matcher = globset::Glob::new(pattern)
164 .map_err(|e| {
165 Error::BadRequest(format!("invalid glob pattern '{pattern}': {e}"))
166 })?
167 .compile_matcher();
168 Ok(
169 image_vec
170 .into_iter()
171 .filter(|img| matcher.is_match(&img.name))
172 .collect(),
173 )
174}
175
176pub async fn validate_image_deletion(
187 infra: &InfraContext<'_>,
188 token: &str,
189 image_id_vec: &[&str],
190 settings_group_name_opt: Option<&str>,
191) -> Result<(), Error> {
192 let (group_available_vec, _target_group_vec) =
195 crate::service::group::resolve_target_and_available_groups(
196 infra,
197 token,
198 settings_group_name_opt,
199 )
200 .await?;
201
202 let boot_parameter_vec = infra.backend.get_all_bootparameters(token).await?;
203
204 let image_used_to_boot_nodes: Vec<String> = boot_parameter_vec
206 .iter()
207 .filter_map(manta_backend_dispatcher::types::bss::BootParameters::try_get_boot_image_id)
208 .collect();
209
210 let image_used_to_boot_nodes_set: std::collections::HashSet<&str> =
214 image_used_to_boot_nodes
215 .iter()
216 .map(String::as_str)
217 .collect();
218 let image_xnames_boot_map: Vec<&&str> = image_id_vec
219 .iter()
220 .filter(|id| image_used_to_boot_nodes_set.contains(**id))
221 .collect();
222
223 if !image_xnames_boot_map.is_empty() {
224 return Err(Error::BadRequest(format!(
225 "The following images could not be deleted \
226 since they boot nodes.\n{}",
227 image_xnames_boot_map
228 .iter()
229 .map(std::string::ToString::to_string)
230 .collect::<Vec<_>>()
231 .join(", ")
232 )));
233 }
234
235 let image_restricted_vec =
237 get_restricted_image_ids(&group_available_vec, &boot_parameter_vec);
238
239 if !image_restricted_vec.is_empty() {
240 return Err(Error::BadRequest(format!(
241 "The following image ids can't be deleted \
242 because they are used by hosts that are not part \
243 of the groups available to the user:\n{}",
244 image_restricted_vec.join(", ")
245 )));
246 }
247
248 Ok(())
249}
250
251pub async fn delete_images(
259 infra: &InfraContext<'_>,
260 token: &str,
261 image_id_vec: &[&str],
262 settings_hsm_group_name_opt: Option<&str>,
263) -> Result<Vec<String>, Error> {
264 validate_image_deletion(
265 infra,
266 token,
267 image_id_vec,
268 settings_hsm_group_name_opt,
269 )
270 .await?;
271
272 let mut deleted = Vec::new();
273 for image_id in image_id_vec {
274 match infra.backend.delete_image(token, image_id).await {
275 Ok(()) => {
276 tracing::info!("Image {} deleted successfully", image_id);
277 deleted.push((*image_id).to_string());
278 }
279 Err(e) => tracing::error!(
280 "Failed to delete image {}: {}. Continuing",
281 image_id,
282 e
283 ),
284 }
285 }
286
287 Ok(deleted)
288}
289
290fn get_restricted_image_ids(
291 group_available_vec: &[Group],
292 boot_parameter_vec: &[BootParameters],
293) -> Vec<String> {
294 get_restricted_boot_parameters(group_available_vec, boot_parameter_vec)
295 .iter()
296 .filter_map(manta_backend_dispatcher::types::bss::BootParameters::try_get_boot_image_id)
297 .collect()
298}
299
300#[cfg(test)]
301mod tests {
302 use super::{apply_date_filter, apply_pattern_filter, sort_and_cap};
314 use chrono::NaiveDateTime;
315 use manta_backend_dispatcher::error::Error;
316 use manta_backend_dispatcher::types::ims::Image;
317
318 fn image(name: &str) -> Image {
319 Image {
320 name: name.to_string(),
321 ..Default::default()
322 }
323 }
324
325 fn image_created(name: &str, created: Option<&str>) -> Image {
326 Image {
327 name: name.to_string(),
328 created: created.map(str::to_string),
329 ..Default::default()
330 }
331 }
332
333 #[test]
334 fn no_pattern_returns_all_images_unchanged() {
335 let input = vec![image("a"), image("b"), image("c")];
336 let out = apply_pattern_filter(input.clone(), None).expect("None is no-op");
337 assert_eq!(out.len(), 3);
338 assert_eq!(out[0].name, "a");
339 assert_eq!(out[2].name, "c");
340 }
341
342 #[test]
343 fn star_glob_matches_everything() {
344 let input = vec![image("compute-a"), image("login-b")];
345 let out = apply_pattern_filter(input, Some("*")).expect("'*' is valid");
346 assert_eq!(out.len(), 2);
347 }
348
349 #[test]
350 fn prefix_star_keeps_only_matching_subset() {
351 let input = vec![
352 image("compute-a"),
353 image("compute-b"),
354 image("login-a"),
355 image("storage-3"),
356 ];
357 let out = apply_pattern_filter(input, Some("compute-*"))
358 .expect("'compute-*' valid");
359 assert_eq!(out.len(), 2);
360 assert!(out.iter().all(|i| i.name.starts_with("compute-")));
361 }
362
363 #[test]
364 fn pattern_with_no_matches_returns_empty() {
365 let input = vec![image("compute-a"), image("login-b")];
366 let out = apply_pattern_filter(input, Some("nomatch-*"))
367 .expect("'nomatch-*' is valid even when nothing matches");
368 assert!(out.is_empty());
369 }
370
371 #[test]
372 fn invalid_glob_returns_bad_request() {
373 let input = vec![image("anything")];
374 let err = apply_pattern_filter(input, Some("[unclosed"))
375 .expect_err("'[unclosed' is malformed");
376 match err {
377 Error::BadRequest(msg) => {
378 assert!(
379 msg.contains("invalid glob pattern"),
380 "error message should explain the glob is bad; got: {msg}"
381 );
382 assert!(
383 msg.contains("'[unclosed'"),
384 "error should quote the offending pattern; got: {msg}"
385 );
386 }
387 other => panic!("expected BadRequest, got {other:?}"),
388 }
389 }
390
391 #[test]
392 fn question_mark_matches_single_char() {
393 let input = vec![
397 image("a"), image("ab"), image("abc"), image("abcd"), ];
402 let out = apply_pattern_filter(input, Some("a??")).expect("'a??' is valid");
403 assert_eq!(out.len(), 1);
404 assert_eq!(out[0].name, "abc");
405 }
406
407 #[test]
408 fn character_class_matches_any_listed_char() {
409 let input = vec![
410 image("compute-a"),
411 image("compute-b"),
412 image("compute-c"),
413 image("compute-d"),
414 ];
415 let out =
416 apply_pattern_filter(input, Some("compute-[abc]")).expect("class valid");
417 assert_eq!(out.len(), 3);
418 assert!(!out.iter().any(|i| i.name == "compute-d"));
419 }
420
421 #[test]
422 fn images_are_ordered_oldest_first() {
423 let input = vec![
424 image_created("middle", Some("2026-03-01T00:00:00")),
425 image_created("oldest", Some("2026-01-01T00:00:00")),
426 image_created("newest", Some("2026-06-01T00:00:00")),
427 ];
428 let out = sort_and_cap(input, None);
429 let names: Vec<&str> = out.iter().map(|i| i.name.as_str()).collect();
430 assert_eq!(
431 names,
432 ["oldest", "middle", "newest"],
433 "listing is oldest-first so the newest image lands at the bottom"
434 );
435 }
436
437 #[test]
438 fn limit_one_keeps_the_newest_not_the_first() {
439 let input = vec![
446 image_created("newest", Some("2026-06-01T00:00:00")),
447 image_created("middle", Some("2026-03-01T00:00:00")),
448 image_created("oldest", Some("2026-01-01T00:00:00")),
449 ];
450 let out = sort_and_cap(input, Some(1));
451 assert_eq!(out.len(), 1);
452 assert_eq!(out[0].name, "newest");
453 }
454
455 #[test]
456 fn limit_keeps_the_newest_n_shown_oldest_first() {
457 let input = vec![
460 image_created("oldest", Some("2026-01-01T00:00:00")),
461 image_created("newest", Some("2026-06-01T00:00:00")),
462 image_created("middle", Some("2026-03-01T00:00:00")),
463 ];
464 let out = sort_and_cap(input, Some(2));
465 let names: Vec<&str> = out.iter().map(|i| i.name.as_str()).collect();
466 assert_eq!(names, ["middle", "newest"]);
467 }
468
469 #[test]
470 fn zoned_timestamps_take_part_in_the_ordering() {
471 let input = vec![
477 image_created("zoned", Some("2026-06-04T12:30:00+00:00")),
478 image_created("newest", Some("2026-12-01T00:00:00")),
479 image_created("oldest", Some("2026-01-01T00:00:00")),
480 ];
481 let out = sort_and_cap(input, None);
482 let names: Vec<&str> = out.iter().map(|i| i.name.as_str()).collect();
483 assert_eq!(names, ["oldest", "zoned", "newest"]);
484 }
485
486 fn ts(raw: &str) -> NaiveDateTime {
487 raw.parse().expect("test timestamp is well-formed")
488 }
489
490 fn dated_fixture() -> Vec<Image> {
491 vec![
492 image_created("jan", Some("2026-01-15T00:00:00")),
493 image_created("mar", Some("2026-03-15T00:00:00")),
494 image_created("jun", Some("2026-06-15T00:00:00")),
495 ]
496 }
497
498 fn names(image_vec: &[Image]) -> Vec<&str> {
499 image_vec.iter().map(|i| i.name.as_str()).collect()
500 }
501
502 #[test]
503 fn no_bounds_is_a_no_op() {
504 let out = apply_date_filter(dated_fixture(), None, None);
505 assert_eq!(names(&out), ["jan", "mar", "jun"]);
506 }
507
508 #[test]
509 fn since_keeps_only_images_at_or_after_the_bound() {
510 let out =
511 apply_date_filter(dated_fixture(), Some(ts("2026-03-01T00:00:00")), None);
512 assert_eq!(names(&out), ["mar", "jun"]);
513 }
514
515 #[test]
516 fn until_keeps_only_images_at_or_before_the_bound() {
517 let out =
518 apply_date_filter(dated_fixture(), None, Some(ts("2026-03-31T00:00:00")));
519 assert_eq!(names(&out), ["jan", "mar"]);
520 }
521
522 #[test]
523 fn both_bounds_select_the_window() {
524 let out = apply_date_filter(
525 dated_fixture(),
526 Some(ts("2026-02-01T00:00:00")),
527 Some(ts("2026-04-01T00:00:00")),
528 );
529 assert_eq!(names(&out), ["mar"]);
530 }
531
532 #[test]
533 fn bounds_are_inclusive_on_both_ends() {
534 let exact = ts("2026-03-15T00:00:00");
537 let out = apply_date_filter(dated_fixture(), Some(exact), Some(exact));
538 assert_eq!(names(&out), ["mar"]);
539 }
540
541 #[test]
542 fn images_with_unusable_dates_are_dropped_when_filtering() {
543 let input = vec![
544 image_created("dated", Some("2026-03-15T00:00:00")),
545 image_created("no-date", None),
546 image_created("bad-date", Some("not-a-real-date")),
547 ];
548 let out = apply_date_filter(input, Some(ts("2026-01-01T00:00:00")), None);
549 assert_eq!(
550 names(&out),
551 ["dated"],
552 "an image with no usable creation date cannot satisfy a date bound"
553 );
554 }
555
556 #[test]
557 fn images_with_unusable_dates_survive_when_not_filtering() {
558 let input = vec![image_created("no-date", None)];
561 let out = apply_date_filter(input, None, None);
562 assert_eq!(names(&out), ["no-date"]);
563 }
564
565 #[test]
566 fn images_without_a_parseable_date_sort_first() {
567 let input = vec![
574 image_created("no-date", None),
575 image_created("bad-date", Some("not-a-real-date")),
576 image_created("dated", Some("2026-01-01T00:00:00")),
577 ];
578 let out = sort_and_cap(input, None);
579 assert_eq!(
580 out.last().unwrap().name,
581 "dated",
582 "the only real date is the newest, so it lands at the bottom"
583 );
584 let head: Vec<&str> = out[..2].iter().map(|i| i.name.as_str()).collect();
589 assert_eq!(head, ["bad-date", "no-date"]);
590 }
591}