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