manta_server/service/
image.rs

1//! IMS image queries and safety-checked deletion.
2//!
3//! Image-deletion has two failure modes that the service layer
4//! refuses to let through:
5//!
6//! 1. Deleting the boot image of a currently-booted node would brick
7//!    the next boot. [`validate_image_deletion`] cross-references
8//!    every candidate id against the BSS boot-parameter records.
9//! 2. Deleting an image referenced by a node outside the caller's
10//!    accessible groups would let users indirectly remove resources
11//!    they don't own.
12//!
13//! Read-only listing ([`get_images`]) only validates the requested
14//! `pattern` glob and applies the `limit` cap.
15
16use 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
27/// Fetch IMS images from the backend, sorted by creation time.
28///
29/// Filters server-side by `params.pattern` (glob syntax, matched
30/// against `image.name`) and caps the result at `params.limit`.
31///
32/// An invalid glob (unbalanced bracket, malformed range, …) returns
33/// [`Error::BadRequest`] with the parser's message; the caller's
34/// handler layer maps that to HTTP 400.
35pub 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
56/// Pure helper that retains only images whose `name` matches `pattern`
57/// (glob syntax). `None` pattern is a no-op pass-through. Split out so
58/// the filter can be unit-tested without standing up an
59/// `InfraContext` / backend mock.
60fn 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
80/// Refuse a planned image delete that would orphan a live boot path
81/// or touch an image scoped to a group the caller can't reach.
82///
83/// Two checks run after access validation: any image listed in
84/// `image_id_vec` that is the current boot image of an existing BSS
85/// record fails with `BadRequest` (deleting it would brick the next
86/// boot); any image whose boot record targets hosts outside the
87/// caller's available groups fails the same way (so a user can't
88/// indirectly remove an image they don't own through a shared id).
89/// Pure check — no deletion happens here.
90pub 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  // One backend fetch + in-memory validation, replacing the prior
97  // three round-trips. See `service::group::resolve_target_and_available_groups`.
98  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  // Check if any requested image is used to boot nodes
109  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  // `image_used_to_boot_nodes` is cluster-scale (one entry per BSS
115  // record). Hash it once so the safety check across user-supplied
116  // delete ids is O(D) rather than O(D·N).
117  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  // Check restricted images
140  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
155/// Run [`validate_image_deletion`] then delete each image in
156/// `image_id_vec`, best-effort.
157///
158/// Individual delete failures are logged and skipped — the function
159/// keeps going so a single backend hiccup doesn't strand the rest of
160/// the batch. The returned vector lists exactly the ids the backend
161/// confirmed removed.
162pub 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  //! Unit tests for the pure `apply_pattern_filter` helper. The
207  //! async wrapper `get_images` adds no logic beyond glue, so testing
208  //! the helper covers the behaviour: pattern compilation, name
209  //! matching, and the BadRequest path on invalid globs.
210
211  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    // Lock the globset semantics for `?`: matches exactly one
283    // character. If we ever swap libraries, this test will fail
284    // and force a deliberate decision rather than silent drift.
285    let input = vec![
286      image("a"),    // 1 char — no match (pattern needs >=2)
287      image("ab"),   // 2 chars — match
288      image("abc"),  // 3 chars — match
289      image("abcd"), // 4 chars — no match
290    ];
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}