use std::cmp::Reverse;
use chrono::NaiveDateTime;
use manta_backend_dispatcher::error::Error;
use manta_backend_dispatcher::interfaces::bss::BootParametersTrait;
use manta_backend_dispatcher::interfaces::ims::ImsTrait;
use manta_backend_dispatcher::types::Group;
use manta_backend_dispatcher::types::bss::BootParameters;
use manta_backend_dispatcher::types::ims::Image;
use manta_shared::common::parse_ims_timestamp;
use crate::server::common::app_context::InfraContext;
use crate::service::boot_parameters::get_restricted_boot_parameters;
use crate::service::configuration::validate_date_range;
pub use manta_shared::types::api::image::GetImagesParams;
pub async fn get_images(
infra: &InfraContext<'_>,
token: &str,
params: &GetImagesParams,
) -> Result<Vec<Image>, Error> {
validate_date_range(params.since, params.until)?;
let mut image_vec = infra
.backend
.get_images(token, params.id.as_deref())
.await?;
image_vec = apply_pattern_filter(image_vec, params.pattern.as_deref())?;
image_vec = apply_date_filter(image_vec, params.since, params.until);
Ok(sort_and_cap(image_vec, params.limit))
}
fn apply_date_filter(
image_vec: Vec<Image>,
since: Option<NaiveDateTime>,
until: Option<NaiveDateTime>,
) -> Vec<Image> {
if since.is_none() && until.is_none() {
return image_vec;
}
image_vec
.into_iter()
.filter(|img| {
let Some(created) = img.created.as_deref().and_then(parse_ims_timestamp)
else {
return false;
};
since.is_none_or(|s| created >= s) && until.is_none_or(|u| created <= u)
})
.collect()
}
fn sort_and_cap(mut image_vec: Vec<Image>, limit: Option<u8>) -> Vec<Image> {
image_vec.sort_by_cached_key(|image| {
Reverse(image.created.as_deref().and_then(parse_ims_timestamp))
});
if let Some(limit) = limit {
image_vec.truncate(limit as usize);
}
image_vec.reverse();
image_vec
}
fn apply_pattern_filter(
image_vec: Vec<Image>,
pattern: Option<&str>,
) -> Result<Vec<Image>, Error> {
let Some(pattern) = pattern else {
return Ok(image_vec);
};
let matcher = globset::Glob::new(pattern)
.map_err(|e| {
Error::BadRequest(format!("invalid glob pattern '{pattern}': {e}"))
})?
.compile_matcher();
Ok(
image_vec
.into_iter()
.filter(|img| matcher.is_match(&img.name))
.collect(),
)
}
pub async fn validate_image_deletion(
infra: &InfraContext<'_>,
token: &str,
image_id_vec: &[&str],
settings_group_name_opt: Option<&str>,
) -> Result<(), Error> {
let (group_available_vec, _target_group_vec) =
crate::service::group::resolve_target_and_available_groups(
infra,
token,
settings_group_name_opt,
)
.await?;
let boot_parameter_vec = infra.backend.get_all_bootparameters(token).await?;
let image_used_to_boot_nodes: Vec<String> = boot_parameter_vec
.iter()
.filter_map(manta_backend_dispatcher::types::bss::BootParameters::try_get_boot_image_id)
.collect();
let image_used_to_boot_nodes_set: std::collections::HashSet<&str> =
image_used_to_boot_nodes
.iter()
.map(String::as_str)
.collect();
let image_xnames_boot_map: Vec<&&str> = image_id_vec
.iter()
.filter(|id| image_used_to_boot_nodes_set.contains(**id))
.collect();
if !image_xnames_boot_map.is_empty() {
return Err(Error::BadRequest(format!(
"The following images could not be deleted \
since they boot nodes.\n{}",
image_xnames_boot_map
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
)));
}
let image_restricted_vec =
get_restricted_image_ids(&group_available_vec, &boot_parameter_vec);
if !image_restricted_vec.is_empty() {
return Err(Error::BadRequest(format!(
"The following image ids can't be deleted \
because they are used by hosts that are not part \
of the groups available to the user:\n{}",
image_restricted_vec.join(", ")
)));
}
Ok(())
}
pub async fn delete_images(
infra: &InfraContext<'_>,
token: &str,
image_id_vec: &[&str],
settings_hsm_group_name_opt: Option<&str>,
) -> Result<Vec<String>, Error> {
validate_image_deletion(
infra,
token,
image_id_vec,
settings_hsm_group_name_opt,
)
.await?;
let mut deleted = Vec::new();
for image_id in image_id_vec {
match infra.backend.delete_image(token, image_id).await {
Ok(()) => {
tracing::info!("Image {} deleted successfully", image_id);
deleted.push((*image_id).to_string());
}
Err(e) => tracing::error!(
"Failed to delete image {}: {}. Continuing",
image_id,
e
),
}
}
Ok(deleted)
}
fn get_restricted_image_ids(
group_available_vec: &[Group],
boot_parameter_vec: &[BootParameters],
) -> Vec<String> {
get_restricted_boot_parameters(group_available_vec, boot_parameter_vec)
.iter()
.filter_map(manta_backend_dispatcher::types::bss::BootParameters::try_get_boot_image_id)
.collect()
}
#[cfg(test)]
mod tests {
use super::{apply_date_filter, apply_pattern_filter, sort_and_cap};
use chrono::NaiveDateTime;
use manta_backend_dispatcher::error::Error;
use manta_backend_dispatcher::types::ims::Image;
fn image(name: &str) -> Image {
Image {
name: name.to_string(),
..Default::default()
}
}
fn image_created(name: &str, created: Option<&str>) -> Image {
Image {
name: name.to_string(),
created: created.map(str::to_string),
..Default::default()
}
}
#[test]
fn no_pattern_returns_all_images_unchanged() {
let input = vec![image("a"), image("b"), image("c")];
let out = apply_pattern_filter(input.clone(), None).expect("None is no-op");
assert_eq!(out.len(), 3);
assert_eq!(out[0].name, "a");
assert_eq!(out[2].name, "c");
}
#[test]
fn star_glob_matches_everything() {
let input = vec![image("compute-a"), image("login-b")];
let out = apply_pattern_filter(input, Some("*")).expect("'*' is valid");
assert_eq!(out.len(), 2);
}
#[test]
fn prefix_star_keeps_only_matching_subset() {
let input = vec![
image("compute-a"),
image("compute-b"),
image("login-a"),
image("storage-3"),
];
let out = apply_pattern_filter(input, Some("compute-*"))
.expect("'compute-*' valid");
assert_eq!(out.len(), 2);
assert!(out.iter().all(|i| i.name.starts_with("compute-")));
}
#[test]
fn pattern_with_no_matches_returns_empty() {
let input = vec![image("compute-a"), image("login-b")];
let out = apply_pattern_filter(input, Some("nomatch-*"))
.expect("'nomatch-*' is valid even when nothing matches");
assert!(out.is_empty());
}
#[test]
fn invalid_glob_returns_bad_request() {
let input = vec![image("anything")];
let err = apply_pattern_filter(input, Some("[unclosed"))
.expect_err("'[unclosed' is malformed");
match err {
Error::BadRequest(msg) => {
assert!(
msg.contains("invalid glob pattern"),
"error message should explain the glob is bad; got: {msg}"
);
assert!(
msg.contains("'[unclosed'"),
"error should quote the offending pattern; got: {msg}"
);
}
other => panic!("expected BadRequest, got {other:?}"),
}
}
#[test]
fn question_mark_matches_single_char() {
let input = vec![
image("a"), image("ab"), image("abc"), image("abcd"), ];
let out = apply_pattern_filter(input, Some("a??")).expect("'a??' is valid");
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "abc");
}
#[test]
fn character_class_matches_any_listed_char() {
let input = vec![
image("compute-a"),
image("compute-b"),
image("compute-c"),
image("compute-d"),
];
let out =
apply_pattern_filter(input, Some("compute-[abc]")).expect("class valid");
assert_eq!(out.len(), 3);
assert!(!out.iter().any(|i| i.name == "compute-d"));
}
#[test]
fn images_are_ordered_oldest_first() {
let input = vec![
image_created("middle", Some("2026-03-01T00:00:00")),
image_created("oldest", Some("2026-01-01T00:00:00")),
image_created("newest", Some("2026-06-01T00:00:00")),
];
let out = sort_and_cap(input, None);
let names: Vec<&str> = out.iter().map(|i| i.name.as_str()).collect();
assert_eq!(
names,
["oldest", "middle", "newest"],
"listing is oldest-first so the newest image lands at the bottom"
);
}
#[test]
fn limit_one_keeps_the_newest_not_the_first() {
let input = vec![
image_created("newest", Some("2026-06-01T00:00:00")),
image_created("middle", Some("2026-03-01T00:00:00")),
image_created("oldest", Some("2026-01-01T00:00:00")),
];
let out = sort_and_cap(input, Some(1));
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "newest");
}
#[test]
fn limit_keeps_the_newest_n_shown_oldest_first() {
let input = vec![
image_created("oldest", Some("2026-01-01T00:00:00")),
image_created("newest", Some("2026-06-01T00:00:00")),
image_created("middle", Some("2026-03-01T00:00:00")),
];
let out = sort_and_cap(input, Some(2));
let names: Vec<&str> = out.iter().map(|i| i.name.as_str()).collect();
assert_eq!(names, ["middle", "newest"]);
}
#[test]
fn zoned_timestamps_take_part_in_the_ordering() {
let input = vec![
image_created("zoned", Some("2026-06-04T12:30:00+00:00")),
image_created("newest", Some("2026-12-01T00:00:00")),
image_created("oldest", Some("2026-01-01T00:00:00")),
];
let out = sort_and_cap(input, None);
let names: Vec<&str> = out.iter().map(|i| i.name.as_str()).collect();
assert_eq!(names, ["oldest", "zoned", "newest"]);
}
fn ts(raw: &str) -> NaiveDateTime {
raw.parse().expect("test timestamp is well-formed")
}
fn dated_fixture() -> Vec<Image> {
vec![
image_created("jan", Some("2026-01-15T00:00:00")),
image_created("mar", Some("2026-03-15T00:00:00")),
image_created("jun", Some("2026-06-15T00:00:00")),
]
}
fn names(image_vec: &[Image]) -> Vec<&str> {
image_vec.iter().map(|i| i.name.as_str()).collect()
}
#[test]
fn no_bounds_is_a_no_op() {
let out = apply_date_filter(dated_fixture(), None, None);
assert_eq!(names(&out), ["jan", "mar", "jun"]);
}
#[test]
fn since_keeps_only_images_at_or_after_the_bound() {
let out =
apply_date_filter(dated_fixture(), Some(ts("2026-03-01T00:00:00")), None);
assert_eq!(names(&out), ["mar", "jun"]);
}
#[test]
fn until_keeps_only_images_at_or_before_the_bound() {
let out =
apply_date_filter(dated_fixture(), None, Some(ts("2026-03-31T00:00:00")));
assert_eq!(names(&out), ["jan", "mar"]);
}
#[test]
fn both_bounds_select_the_window() {
let out = apply_date_filter(
dated_fixture(),
Some(ts("2026-02-01T00:00:00")),
Some(ts("2026-04-01T00:00:00")),
);
assert_eq!(names(&out), ["mar"]);
}
#[test]
fn bounds_are_inclusive_on_both_ends() {
let exact = ts("2026-03-15T00:00:00");
let out = apply_date_filter(dated_fixture(), Some(exact), Some(exact));
assert_eq!(names(&out), ["mar"]);
}
#[test]
fn images_with_unusable_dates_are_dropped_when_filtering() {
let input = vec![
image_created("dated", Some("2026-03-15T00:00:00")),
image_created("no-date", None),
image_created("bad-date", Some("not-a-real-date")),
];
let out = apply_date_filter(input, Some(ts("2026-01-01T00:00:00")), None);
assert_eq!(
names(&out),
["dated"],
"an image with no usable creation date cannot satisfy a date bound"
);
}
#[test]
fn images_with_unusable_dates_survive_when_not_filtering() {
let input = vec![image_created("no-date", None)];
let out = apply_date_filter(input, None, None);
assert_eq!(names(&out), ["no-date"]);
}
#[test]
fn images_without_a_parseable_date_sort_first() {
let input = vec![
image_created("no-date", None),
image_created("bad-date", Some("not-a-real-date")),
image_created("dated", Some("2026-01-01T00:00:00")),
];
let out = sort_and_cap(input, None);
assert_eq!(
out.last().unwrap().name,
"dated",
"the only real date is the newest, so it lands at the bottom"
);
let head: Vec<&str> = out[..2].iter().map(|i| i.name.as_str()).collect();
assert_eq!(head, ["bad-date", "no-date"]);
}
}