manta_server/service/
image.rs1use manta_backend_dispatcher::error::Error;
17use manta_backend_dispatcher::interfaces::bss::BootParametersTrait;
18use manta_backend_dispatcher::interfaces::ims::ImsTrait;
19use manta_backend_dispatcher::types::Group;
20use manta_backend_dispatcher::types::bss::BootParameters;
21use manta_backend_dispatcher::types::ims::Image;
22
23use crate::server::common::app_context::InfraContext;
24use crate::service::boot_parameters::get_restricted_boot_parameters;
25pub use manta_shared::types::api::image::GetImagesParams;
26
27pub async fn get_images(
36 infra: &InfraContext<'_>,
37 token: &str,
38 params: &GetImagesParams,
39) -> Result<Vec<Image>, Error> {
40 let mut image_vec = infra
41 .backend
42 .get_images(token, params.id.as_deref())
43 .await?;
44
45 image_vec = apply_pattern_filter(image_vec, params.pattern.as_deref())?;
46
47 if let Some(limit) = params.limit {
48 image_vec.truncate(limit as usize);
49 }
50
51 image_vec.sort_by_key(|image| image.created.clone());
52
53 Ok(image_vec)
54}
55
56fn apply_pattern_filter(
61 image_vec: Vec<Image>,
62 pattern: Option<&str>,
63) -> Result<Vec<Image>, Error> {
64 let Some(pattern) = pattern else {
65 return Ok(image_vec);
66 };
67 let matcher = globset::Glob::new(pattern)
68 .map_err(|e| {
69 Error::BadRequest(format!("invalid glob pattern '{pattern}': {e}"))
70 })?
71 .compile_matcher();
72 Ok(
73 image_vec
74 .into_iter()
75 .filter(|img| matcher.is_match(&img.name))
76 .collect(),
77 )
78}
79
80pub async fn validate_image_deletion(
91 infra: &InfraContext<'_>,
92 token: &str,
93 image_id_vec: &[&str],
94 settings_group_name_opt: Option<&str>,
95) -> Result<(), Error> {
96 let (group_available_vec, _target_group_vec) =
99 crate::service::group::resolve_target_and_available_groups(
100 infra,
101 token,
102 settings_group_name_opt,
103 )
104 .await?;
105
106 let boot_parameter_vec = infra.backend.get_all_bootparameters(token).await?;
107
108 let image_used_to_boot_nodes: Vec<String> = boot_parameter_vec
110 .iter()
111 .filter_map(manta_backend_dispatcher::types::bss::BootParameters::try_get_boot_image_id)
112 .collect();
113
114 let image_used_to_boot_nodes_set: std::collections::HashSet<&str> =
118 image_used_to_boot_nodes
119 .iter()
120 .map(String::as_str)
121 .collect();
122 let image_xnames_boot_map: Vec<&&str> = image_id_vec
123 .iter()
124 .filter(|id| image_used_to_boot_nodes_set.contains(**id))
125 .collect();
126
127 if !image_xnames_boot_map.is_empty() {
128 return Err(Error::BadRequest(format!(
129 "The following images could not be deleted \
130 since they boot nodes.\n{}",
131 image_xnames_boot_map
132 .iter()
133 .map(std::string::ToString::to_string)
134 .collect::<Vec<_>>()
135 .join(", ")
136 )));
137 }
138
139 let image_restricted_vec =
141 get_restricted_image_ids(&group_available_vec, &boot_parameter_vec);
142
143 if !image_restricted_vec.is_empty() {
144 return Err(Error::BadRequest(format!(
145 "The following image ids can't be deleted \
146 because they are used by hosts that are not part \
147 of the groups available to the user:\n{}",
148 image_restricted_vec.join(", ")
149 )));
150 }
151
152 Ok(())
153}
154
155pub async fn delete_images(
163 infra: &InfraContext<'_>,
164 token: &str,
165 image_id_vec: &[&str],
166 settings_hsm_group_name_opt: Option<&str>,
167) -> Result<Vec<String>, Error> {
168 validate_image_deletion(
169 infra,
170 token,
171 image_id_vec,
172 settings_hsm_group_name_opt,
173 )
174 .await?;
175
176 let mut deleted = Vec::new();
177 for image_id in image_id_vec {
178 match infra.backend.delete_image(token, image_id).await {
179 Ok(()) => {
180 tracing::info!("Image {} deleted successfully", image_id);
181 deleted.push((*image_id).to_string());
182 }
183 Err(e) => tracing::error!(
184 "Failed to delete image {}: {}. Continuing",
185 image_id,
186 e
187 ),
188 }
189 }
190
191 Ok(deleted)
192}
193
194fn get_restricted_image_ids(
195 group_available_vec: &[Group],
196 boot_parameter_vec: &[BootParameters],
197) -> Vec<String> {
198 get_restricted_boot_parameters(group_available_vec, boot_parameter_vec)
199 .iter()
200 .filter_map(manta_backend_dispatcher::types::bss::BootParameters::try_get_boot_image_id)
201 .collect()
202}
203
204#[cfg(test)]
205mod tests {
206 use super::apply_pattern_filter;
212 use manta_backend_dispatcher::error::Error;
213 use manta_backend_dispatcher::types::ims::Image;
214
215 fn image(name: &str) -> Image {
216 Image {
217 name: name.to_string(),
218 ..Default::default()
219 }
220 }
221
222 #[test]
223 fn no_pattern_returns_all_images_unchanged() {
224 let input = vec![image("a"), image("b"), image("c")];
225 let out = apply_pattern_filter(input.clone(), None).expect("None is no-op");
226 assert_eq!(out.len(), 3);
227 assert_eq!(out[0].name, "a");
228 assert_eq!(out[2].name, "c");
229 }
230
231 #[test]
232 fn star_glob_matches_everything() {
233 let input = vec![image("compute-a"), image("login-b")];
234 let out = apply_pattern_filter(input, Some("*")).expect("'*' is valid");
235 assert_eq!(out.len(), 2);
236 }
237
238 #[test]
239 fn prefix_star_keeps_only_matching_subset() {
240 let input = vec![
241 image("compute-a"),
242 image("compute-b"),
243 image("login-a"),
244 image("storage-3"),
245 ];
246 let out = apply_pattern_filter(input, Some("compute-*"))
247 .expect("'compute-*' valid");
248 assert_eq!(out.len(), 2);
249 assert!(out.iter().all(|i| i.name.starts_with("compute-")));
250 }
251
252 #[test]
253 fn pattern_with_no_matches_returns_empty() {
254 let input = vec![image("compute-a"), image("login-b")];
255 let out = apply_pattern_filter(input, Some("nomatch-*"))
256 .expect("'nomatch-*' is valid even when nothing matches");
257 assert!(out.is_empty());
258 }
259
260 #[test]
261 fn invalid_glob_returns_bad_request() {
262 let input = vec![image("anything")];
263 let err = apply_pattern_filter(input, Some("[unclosed"))
264 .expect_err("'[unclosed' is malformed");
265 match err {
266 Error::BadRequest(msg) => {
267 assert!(
268 msg.contains("invalid glob pattern"),
269 "error message should explain the glob is bad; got: {msg}"
270 );
271 assert!(
272 msg.contains("'[unclosed'"),
273 "error should quote the offending pattern; got: {msg}"
274 );
275 }
276 other => panic!("expected BadRequest, got {other:?}"),
277 }
278 }
279
280 #[test]
281 fn question_mark_matches_single_char() {
282 let input = vec![
286 image("a"), image("ab"), image("abc"), image("abcd"), ];
291 let out = apply_pattern_filter(input, Some("a??")).expect("'a??' is valid");
292 assert_eq!(out.len(), 1);
293 assert_eq!(out[0].name, "abc");
294 }
295
296 #[test]
297 fn character_class_matches_any_listed_char() {
298 let input = vec![
299 image("compute-a"),
300 image("compute-b"),
301 image("compute-c"),
302 image("compute-d"),
303 ];
304 let out =
305 apply_pattern_filter(input, Some("compute-[abc]")).expect("class valid");
306 assert_eq!(out.len(), 3);
307 assert!(!out.iter().any(|i| i.name == "compute-d"));
308 }
309}