manta_server/service/
image.rs

1//! IMS image queries and safety-checked deletion (rejects images that boot live nodes).
2
3use 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
14/// Fetch IMS images from the backend, sorted by creation time.
15///
16/// Filters server-side by `params.pattern` (glob syntax, matched
17/// against `image.name`) and caps the result at `params.limit`.
18///
19/// An invalid glob (unbalanced bracket, malformed range, …) returns
20/// [`Error::BadRequest`] with the parser's message; the caller's
21/// handler layer maps that to HTTP 400.
22pub 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
43/// Pure helper that retains only images whose `name` matches `pattern`
44/// (glob syntax). `None` pattern is a no-op pass-through. Split out so
45/// the filter can be unit-tested without standing up an
46/// `InfraContext` / backend mock.
47fn 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
67/// Refuse a planned image delete that would orphan a live boot path
68/// or touch an image scoped to a group the caller can't reach.
69///
70/// Two checks run after access validation: any image listed in
71/// `image_id_vec` that is the current boot image of an existing BSS
72/// record fails with `BadRequest` (deleting it would brick the next
73/// boot); any image whose boot record targets hosts outside the
74/// caller's available groups fails the same way (so a user can't
75/// indirectly remove an image they don't own through a shared id).
76/// Pure check — no deletion happens here.
77pub 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  // One backend fetch + in-memory validation, replacing the prior
84  // three round-trips. See `service::group::resolve_target_and_available_groups`.
85  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  // Check if any requested image is used to boot nodes
96  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  // `image_used_to_boot_nodes` is cluster-scale (one entry per BSS
107  // record). Hash it once so the safety check across user-supplied
108  // delete ids is O(D) rather than O(D·N).
109  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  // Check restricted images
132  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
153/// Run [`validate_image_deletion`] then delete each image in
154/// `image_id_vec`, best-effort.
155///
156/// Individual delete failures are logged and skipped — the function
157/// keeps going so a single backend hiccup doesn't strand the rest of
158/// the batch. The returned vector lists exactly the ids the backend
159/// confirmed removed.
160pub 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  //! Unit tests for the pure `apply_pattern_filter` helper. The
205  //! async wrapper `get_images` adds no logic beyond glue, so testing
206  //! the helper covers the behaviour: pattern compilation, name
207  //! matching, and the BadRequest path on invalid globs.
208
209  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    // Lock the globset semantics for `?`: matches exactly one
281    // character. If we ever swap libraries, this test will fail
282    // and force a deliberate decision rather than silent drift.
283    let input = vec![
284      image("a"),    // 1 char — no match (pattern needs >=2)
285      image("ab"),   // 2 chars — match
286      image("abc"),  // 3 chars — match
287      image("abcd"), // 4 chars — no match
288    ];
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}