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`]) validates the requested
14//! `pattern` glob and `since`/`until` range, filters on both, selects
15//! the newest `limit` images, and returns them oldest-first (newest
16//! last). IMS takes no query parameters of its own, so every filter
17//! runs here.
18
19use std::cmp::Reverse;
20
21use chrono::NaiveDateTime;
22use manta_backend_dispatcher::error::Error;
23use manta_backend_dispatcher::interfaces::bss::BootParametersTrait;
24use manta_backend_dispatcher::interfaces::ims::ImsTrait;
25use manta_backend_dispatcher::types::Group;
26use manta_backend_dispatcher::types::bss::BootParameters;
27use manta_backend_dispatcher::types::ims::Image;
28use manta_shared::common::parse_ims_timestamp;
29
30use crate::server::common::app_context::InfraContext;
31use crate::service::boot_parameters::get_restricted_boot_parameters;
32use crate::service::configuration::validate_date_range;
33pub use manta_shared::types::api::image::GetImagesParams;
34
35/// Fetch IMS images from the backend, oldest-first (newest last).
36///
37/// Filters server-side by `params.pattern` (glob syntax, matched
38/// against `image.name`) and by the `params.since` / `params.until`
39/// creation-date bounds, then selects the newest `params.limit` images
40/// and returns them oldest-first for display. Selection happens before
41/// the display flip, so `limit = 1` yields the single newest image (see
42/// `sort_and_cap`).
43///
44/// Both filters run here rather than at the backend because IMS
45/// accepts no query parameters at all — `ImsTrait::get_images` takes
46/// only an optional image id.
47///
48/// An invalid glob (unbalanced bracket, malformed range, …) or a
49/// `since` later than `until` returns [`Error::BadRequest`]; the
50/// caller's handler layer maps that to HTTP 400.
51pub async fn get_images(
52  infra: &InfraContext<'_>,
53  token: &str,
54  params: &GetImagesParams,
55) -> Result<Vec<Image>, Error> {
56  validate_date_range(params.since, params.until)?;
57
58  let mut image_vec = infra
59    .backend
60    .get_images(token, params.id.as_deref())
61    .await?;
62
63  image_vec = apply_pattern_filter(image_vec, params.pattern.as_deref())?;
64  image_vec = apply_date_filter(image_vec, params.since, params.until);
65
66  Ok(sort_and_cap(image_vec, params.limit))
67}
68
69/// Pure helper retaining only images created within `[since, until]`.
70///
71/// Both bounds are inclusive and independently optional; `None` for
72/// both is a no-op pass-through.
73///
74/// An image whose `created` is absent or unparseable is **dropped**
75/// whenever either bound is set: it cannot be shown to satisfy
76/// "created after X", so including it would misreport the filter the
77/// caller asked for. This matches how `manta get images` already
78/// treats an unknown `safe_to_delete` verdict under
79/// `--only-safe-to-delete`.
80///
81/// A zoned `created` is normalised to naive local time (see
82/// [`parse_ims_timestamp`]) before comparison, using **this process's**
83/// timezone — i.e. the server's. The CLI renders the Creation time
84/// column the same way but in the *client's* timezone, so for an
85/// offset-bearing `created` a displayed time can differ from the value
86/// filtered against when client and server timezones disagree. The
87/// server-side comparison is authoritative; the two agree on which
88/// timestamps are readable, not necessarily on their wall-clock value.
89fn apply_date_filter(
90  image_vec: Vec<Image>,
91  since: Option<NaiveDateTime>,
92  until: Option<NaiveDateTime>,
93) -> Vec<Image> {
94  if since.is_none() && until.is_none() {
95    return image_vec;
96  }
97
98  image_vec
99    .into_iter()
100    .filter(|img| {
101      let Some(created) = img.created.as_deref().and_then(parse_ims_timestamp)
102      else {
103        return false;
104      };
105      since.is_none_or(|s| created >= s) && until.is_none_or(|u| created <= u)
106    })
107    .collect()
108}
109
110/// Select the newest `limit` images and return them oldest-first
111/// (newest last).
112///
113/// Three steps whose order is the whole contract:
114/// 1. sort newest-first, so
115/// 2. `truncate(limit)` keeps the newest N — capping the backend's
116///    arbitrary order instead would make `limit = 1` return an
117///    arbitrary image rather than the most recent one; then
118/// 3. `reverse()` flips the kept images to oldest-first for display, so
119///    the newest lands at the bottom of the listing.
120///
121/// Selection (newest N) and display order (oldest-first) are decoupled
122/// on purpose: `--limit` / `--most-recent` still pick the most recent
123/// images, they're just printed with the newest at the end — matching
124/// `get configurations` / `get sessions`, whose csm-rs listings are
125/// already ascending. Keeping all three steps in one pure function
126/// makes the contract testable without standing up a backend mock.
127///
128/// The sort key is the *parsed* timestamp ([`parse_ims_timestamp`]),
129/// not the raw string: `created` arrives in a shape CSM does not
130/// guarantee, and a lexicographic order only matches chronology when
131/// every value shares one shape (mixed zoned/naive break it). `Reverse`
132/// gives the newest-first working order and sends absent/unparseable
133/// dates (`None`) to the end — so after the final `reverse` they sit at
134/// the *top*, treated as oldest, as `get sessions` treats undated rows.
135/// `sort_by_cached_key` parses each `created` once — not `O(n log n)`
136/// times as a comparator would — and is stable, so equal or unplaceable
137/// images keep the backend's order.
138fn sort_and_cap(mut image_vec: Vec<Image>, limit: Option<u8>) -> Vec<Image> {
139  image_vec.sort_by_cached_key(|image| {
140    Reverse(image.created.as_deref().and_then(parse_ims_timestamp))
141  });
142
143  if let Some(limit) = limit {
144    image_vec.truncate(limit as usize);
145  }
146
147  image_vec.reverse();
148
149  image_vec
150}
151
152/// Pure helper that retains only images whose `name` matches `pattern`
153/// (glob syntax). `None` pattern is a no-op pass-through. Split out so
154/// the filter can be unit-tested without standing up an
155/// `InfraContext` / backend mock.
156fn apply_pattern_filter(
157  image_vec: Vec<Image>,
158  pattern: Option<&str>,
159) -> Result<Vec<Image>, Error> {
160  let Some(pattern) = pattern else {
161    return Ok(image_vec);
162  };
163  let matcher = globset::Glob::new(pattern)
164    .map_err(|e| {
165      Error::BadRequest(format!("invalid glob pattern '{pattern}': {e}"))
166    })?
167    .compile_matcher();
168  Ok(
169    image_vec
170      .into_iter()
171      .filter(|img| matcher.is_match(&img.name))
172      .collect(),
173  )
174}
175
176/// Refuse a planned image delete that would orphan a live boot path
177/// or touch an image scoped to a group the caller can't reach.
178///
179/// Two checks run after access validation: any image listed in
180/// `image_id_vec` that is the current boot image of an existing BSS
181/// record fails with `BadRequest` (deleting it would brick the next
182/// boot); any image whose boot record targets hosts outside the
183/// caller's available groups fails the same way (so a user can't
184/// indirectly remove an image they don't own through a shared id).
185/// Pure check — no deletion happens here.
186pub async fn validate_image_deletion(
187  infra: &InfraContext<'_>,
188  token: &str,
189  image_id_vec: &[&str],
190  settings_group_name_opt: Option<&str>,
191) -> Result<(), Error> {
192  // One backend fetch + in-memory validation, replacing the prior
193  // three round-trips. See `service::group::resolve_target_and_available_groups`.
194  let (group_available_vec, _target_group_vec) =
195    crate::service::group::resolve_target_and_available_groups(
196      infra,
197      token,
198      settings_group_name_opt,
199    )
200    .await?;
201
202  let boot_parameter_vec = infra.backend.get_all_bootparameters(token).await?;
203
204  // Check if any requested image is used to boot nodes
205  let image_used_to_boot_nodes: Vec<String> = boot_parameter_vec
206    .iter()
207    .filter_map(manta_backend_dispatcher::types::bss::BootParameters::try_get_boot_image_id)
208    .collect();
209
210  // `image_used_to_boot_nodes` is cluster-scale (one entry per BSS
211  // record). Hash it once so the safety check across user-supplied
212  // delete ids is O(D) rather than O(D·N).
213  let image_used_to_boot_nodes_set: std::collections::HashSet<&str> =
214    image_used_to_boot_nodes
215      .iter()
216      .map(String::as_str)
217      .collect();
218  let image_xnames_boot_map: Vec<&&str> = image_id_vec
219    .iter()
220    .filter(|id| image_used_to_boot_nodes_set.contains(**id))
221    .collect();
222
223  if !image_xnames_boot_map.is_empty() {
224    return Err(Error::BadRequest(format!(
225      "The following images could not be deleted \
226       since they boot nodes.\n{}",
227      image_xnames_boot_map
228        .iter()
229        .map(std::string::ToString::to_string)
230        .collect::<Vec<_>>()
231        .join(", ")
232    )));
233  }
234
235  // Check restricted images
236  let image_restricted_vec =
237    get_restricted_image_ids(&group_available_vec, &boot_parameter_vec);
238
239  if !image_restricted_vec.is_empty() {
240    return Err(Error::BadRequest(format!(
241      "The following image ids can't be deleted \
242       because they are used by hosts that are not part \
243       of the groups available to the user:\n{}",
244      image_restricted_vec.join(", ")
245    )));
246  }
247
248  Ok(())
249}
250
251/// Run [`validate_image_deletion`] then delete each image in
252/// `image_id_vec`, best-effort.
253///
254/// Individual delete failures are logged and skipped — the function
255/// keeps going so a single backend hiccup doesn't strand the rest of
256/// the batch. The returned vector lists exactly the ids the backend
257/// confirmed removed.
258pub async fn delete_images(
259  infra: &InfraContext<'_>,
260  token: &str,
261  image_id_vec: &[&str],
262  settings_hsm_group_name_opt: Option<&str>,
263) -> Result<Vec<String>, Error> {
264  validate_image_deletion(
265    infra,
266    token,
267    image_id_vec,
268    settings_hsm_group_name_opt,
269  )
270  .await?;
271
272  let mut deleted = Vec::new();
273  for image_id in image_id_vec {
274    match infra.backend.delete_image(token, image_id).await {
275      Ok(()) => {
276        tracing::info!("Image {} deleted successfully", image_id);
277        deleted.push((*image_id).to_string());
278      }
279      Err(e) => tracing::error!(
280        "Failed to delete image {}: {}. Continuing",
281        image_id,
282        e
283      ),
284    }
285  }
286
287  Ok(deleted)
288}
289
290fn get_restricted_image_ids(
291  group_available_vec: &[Group],
292  boot_parameter_vec: &[BootParameters],
293) -> Vec<String> {
294  get_restricted_boot_parameters(group_available_vec, boot_parameter_vec)
295    .iter()
296    .filter_map(manta_backend_dispatcher::types::bss::BootParameters::try_get_boot_image_id)
297    .collect()
298}
299
300#[cfg(test)]
301mod tests {
302  //! Unit tests for the pure helpers behind `get_images`:
303  //! `apply_pattern_filter` (pattern compilation, name matching, and
304  //! the BadRequest path on invalid globs), `apply_date_filter`
305  //! (inclusive since/until bounds and unusable dates), and
306  //! `sort_and_cap` (newest-N selection with oldest-first display, and
307  //! the cap applying only after the sort).
308  //!
309  //! These call the same functions `get_images` calls, so the
310  //! order-of-operations contract is genuinely covered here rather
311  //! than restated.
312
313  use super::{apply_date_filter, apply_pattern_filter, sort_and_cap};
314  use chrono::NaiveDateTime;
315  use manta_backend_dispatcher::error::Error;
316  use manta_backend_dispatcher::types::ims::Image;
317
318  fn image(name: &str) -> Image {
319    Image {
320      name: name.to_string(),
321      ..Default::default()
322    }
323  }
324
325  fn image_created(name: &str, created: Option<&str>) -> Image {
326    Image {
327      name: name.to_string(),
328      created: created.map(str::to_string),
329      ..Default::default()
330    }
331  }
332
333  #[test]
334  fn no_pattern_returns_all_images_unchanged() {
335    let input = vec![image("a"), image("b"), image("c")];
336    let out = apply_pattern_filter(input.clone(), None).expect("None is no-op");
337    assert_eq!(out.len(), 3);
338    assert_eq!(out[0].name, "a");
339    assert_eq!(out[2].name, "c");
340  }
341
342  #[test]
343  fn star_glob_matches_everything() {
344    let input = vec![image("compute-a"), image("login-b")];
345    let out = apply_pattern_filter(input, Some("*")).expect("'*' is valid");
346    assert_eq!(out.len(), 2);
347  }
348
349  #[test]
350  fn prefix_star_keeps_only_matching_subset() {
351    let input = vec![
352      image("compute-a"),
353      image("compute-b"),
354      image("login-a"),
355      image("storage-3"),
356    ];
357    let out = apply_pattern_filter(input, Some("compute-*"))
358      .expect("'compute-*' valid");
359    assert_eq!(out.len(), 2);
360    assert!(out.iter().all(|i| i.name.starts_with("compute-")));
361  }
362
363  #[test]
364  fn pattern_with_no_matches_returns_empty() {
365    let input = vec![image("compute-a"), image("login-b")];
366    let out = apply_pattern_filter(input, Some("nomatch-*"))
367      .expect("'nomatch-*' is valid even when nothing matches");
368    assert!(out.is_empty());
369  }
370
371  #[test]
372  fn invalid_glob_returns_bad_request() {
373    let input = vec![image("anything")];
374    let err = apply_pattern_filter(input, Some("[unclosed"))
375      .expect_err("'[unclosed' is malformed");
376    match err {
377      Error::BadRequest(msg) => {
378        assert!(
379          msg.contains("invalid glob pattern"),
380          "error message should explain the glob is bad; got: {msg}"
381        );
382        assert!(
383          msg.contains("'[unclosed'"),
384          "error should quote the offending pattern; got: {msg}"
385        );
386      }
387      other => panic!("expected BadRequest, got {other:?}"),
388    }
389  }
390
391  #[test]
392  fn question_mark_matches_single_char() {
393    // Lock the globset semantics for `?`: matches exactly one
394    // character. If we ever swap libraries, this test will fail
395    // and force a deliberate decision rather than silent drift.
396    let input = vec![
397      image("a"),    // 1 char — no match (pattern needs >=2)
398      image("ab"),   // 2 chars — match
399      image("abc"),  // 3 chars — match
400      image("abcd"), // 4 chars — no match
401    ];
402    let out = apply_pattern_filter(input, Some("a??")).expect("'a??' is valid");
403    assert_eq!(out.len(), 1);
404    assert_eq!(out[0].name, "abc");
405  }
406
407  #[test]
408  fn character_class_matches_any_listed_char() {
409    let input = vec![
410      image("compute-a"),
411      image("compute-b"),
412      image("compute-c"),
413      image("compute-d"),
414    ];
415    let out =
416      apply_pattern_filter(input, Some("compute-[abc]")).expect("class valid");
417    assert_eq!(out.len(), 3);
418    assert!(!out.iter().any(|i| i.name == "compute-d"));
419  }
420
421  #[test]
422  fn images_are_ordered_oldest_first() {
423    let input = vec![
424      image_created("middle", Some("2026-03-01T00:00:00")),
425      image_created("oldest", Some("2026-01-01T00:00:00")),
426      image_created("newest", Some("2026-06-01T00:00:00")),
427    ];
428    let out = sort_and_cap(input, None);
429    let names: Vec<&str> = out.iter().map(|i| i.name.as_str()).collect();
430    assert_eq!(
431      names,
432      ["oldest", "middle", "newest"],
433      "listing is oldest-first so the newest image lands at the bottom"
434    );
435  }
436
437  #[test]
438  fn limit_one_keeps_the_newest_not_the_first() {
439    // Regression: the cap used to be applied *before* the sort, so
440    // `--most-recent` returned whatever the backend happened to list
441    // first. Selection is still "newest N" even though display is now
442    // oldest-first — with limit 1 the single row must be the newest.
443    // "newest" is deliberately first in backend order to catch a naive
444    // "take the front" cap.
445    let input = vec![
446      image_created("newest", Some("2026-06-01T00:00:00")),
447      image_created("middle", Some("2026-03-01T00:00:00")),
448      image_created("oldest", Some("2026-01-01T00:00:00")),
449    ];
450    let out = sort_and_cap(input, Some(1));
451    assert_eq!(out.len(), 1);
452    assert_eq!(out[0].name, "newest");
453  }
454
455  #[test]
456  fn limit_keeps_the_newest_n_shown_oldest_first() {
457    // `--limit 2` selects the two newest, then displays them
458    // oldest-first: "middle" above "newest", and "oldest" dropped.
459    let input = vec![
460      image_created("oldest", Some("2026-01-01T00:00:00")),
461      image_created("newest", Some("2026-06-01T00:00:00")),
462      image_created("middle", Some("2026-03-01T00:00:00")),
463    ];
464    let out = sort_and_cap(input, Some(2));
465    let names: Vec<&str> = out.iter().map(|i| i.name.as_str()).collect();
466    assert_eq!(names, ["middle", "newest"]);
467  }
468
469  #[test]
470  fn zoned_timestamps_take_part_in_the_ordering() {
471    // A zoned `created` must be parsed and placed on the timeline, not
472    // written off as unparseable. The months-wide gaps keep this
473    // independent of the runner's timezone: `parse_ims_timestamp`
474    // normalises to local naive time, so a zoned value shifts by at
475    // most ±14h — nowhere near enough to escape the surrounding pair.
476    let input = vec![
477      image_created("zoned", Some("2026-06-04T12:30:00+00:00")),
478      image_created("newest", Some("2026-12-01T00:00:00")),
479      image_created("oldest", Some("2026-01-01T00:00:00")),
480    ];
481    let out = sort_and_cap(input, None);
482    let names: Vec<&str> = out.iter().map(|i| i.name.as_str()).collect();
483    assert_eq!(names, ["oldest", "zoned", "newest"]);
484  }
485
486  fn ts(raw: &str) -> NaiveDateTime {
487    raw.parse().expect("test timestamp is well-formed")
488  }
489
490  fn dated_fixture() -> Vec<Image> {
491    vec![
492      image_created("jan", Some("2026-01-15T00:00:00")),
493      image_created("mar", Some("2026-03-15T00:00:00")),
494      image_created("jun", Some("2026-06-15T00:00:00")),
495    ]
496  }
497
498  fn names(image_vec: &[Image]) -> Vec<&str> {
499    image_vec.iter().map(|i| i.name.as_str()).collect()
500  }
501
502  #[test]
503  fn no_bounds_is_a_no_op() {
504    let out = apply_date_filter(dated_fixture(), None, None);
505    assert_eq!(names(&out), ["jan", "mar", "jun"]);
506  }
507
508  #[test]
509  fn since_keeps_only_images_at_or_after_the_bound() {
510    let out =
511      apply_date_filter(dated_fixture(), Some(ts("2026-03-01T00:00:00")), None);
512    assert_eq!(names(&out), ["mar", "jun"]);
513  }
514
515  #[test]
516  fn until_keeps_only_images_at_or_before_the_bound() {
517    let out =
518      apply_date_filter(dated_fixture(), None, Some(ts("2026-03-31T00:00:00")));
519    assert_eq!(names(&out), ["jan", "mar"]);
520  }
521
522  #[test]
523  fn both_bounds_select_the_window() {
524    let out = apply_date_filter(
525      dated_fixture(),
526      Some(ts("2026-02-01T00:00:00")),
527      Some(ts("2026-04-01T00:00:00")),
528    );
529    assert_eq!(names(&out), ["mar"]);
530  }
531
532  #[test]
533  fn bounds_are_inclusive_on_both_ends() {
534    // An image created exactly on the bound is kept — `--since X
535    // --until X` must not return an empty list for an image at X.
536    let exact = ts("2026-03-15T00:00:00");
537    let out = apply_date_filter(dated_fixture(), Some(exact), Some(exact));
538    assert_eq!(names(&out), ["mar"]);
539  }
540
541  #[test]
542  fn images_with_unusable_dates_are_dropped_when_filtering() {
543    let input = vec![
544      image_created("dated", Some("2026-03-15T00:00:00")),
545      image_created("no-date", None),
546      image_created("bad-date", Some("not-a-real-date")),
547    ];
548    let out = apply_date_filter(input, Some(ts("2026-01-01T00:00:00")), None);
549    assert_eq!(
550      names(&out),
551      ["dated"],
552      "an image with no usable creation date cannot satisfy a date bound"
553    );
554  }
555
556  #[test]
557  fn images_with_unusable_dates_survive_when_not_filtering() {
558    // The drop above is a consequence of filtering, not a general
559    // rule: an unfiltered listing must still show them.
560    let input = vec![image_created("no-date", None)];
561    let out = apply_date_filter(input, None, None);
562    assert_eq!(names(&out), ["no-date"]);
563  }
564
565  #[test]
566  fn images_without_a_parseable_date_sort_first() {
567    // Unplaceable images (missing or unparseable `created`) are treated
568    // as oldest, so in the oldest-first listing they sit at the top,
569    // above every real date — the newest-last row stays at the bottom.
570    // Also pins that ordering is by *parsed* value, not raw string:
571    // "not-a-real-date" starts with 'n', so a naive string sort could
572    // misplace it relative to the "2026-..." timestamp.
573    let input = vec![
574      image_created("no-date", None),
575      image_created("bad-date", Some("not-a-real-date")),
576      image_created("dated", Some("2026-01-01T00:00:00")),
577    ];
578    let out = sort_and_cap(input, None);
579    assert_eq!(
580      out.last().unwrap().name,
581      "dated",
582      "the only real date is the newest, so it lands at the bottom"
583    );
584    // The two undated rows lead. `sort_by_cached_key` is stable, so
585    // before the display flip they held backend order (no-date,
586    // bad-date); the final `reverse` flips the whole list, undated rows
587    // included, so they appear reversed here.
588    let head: Vec<&str> = out[..2].iter().map(|i| i.name.as_str()).collect();
589    assert_eq!(head, ["bad-date", "no-date"]);
590  }
591}