manta_server/service/
analysis.rs

1//! Cross-resource analyses that fan IMS / CFS / BSS fetches and link
2//! the results in a pure helper.
3//!
4//! - [`get_image_analysis`] + [`build_cache`] — image-centric flat
5//!   projection; one row per IMS image with a `safe_to_delete` verdict
6//!   derived from BSS boot-parameter references. See [`BackendSummary`].
7//! - [`build_configuration_analysis`] — pure linker that derives a
8//!   `safe_to_delete` verdict per CFS configuration from CFS components
9//!   and (optionally) BSS-referenced images. Called from
10//!   `service::configuration::get_configurations_with_analysis` (the
11//!   components-only variant served at `/configurations`).
12
13use std::collections::{HashMap, HashSet};
14
15use manta_backend_dispatcher::error::Error;
16use manta_backend_dispatcher::interfaces::bss::BootParametersTrait;
17use manta_backend_dispatcher::types::bss::BootParameters;
18use manta_backend_dispatcher::types::cfs::cfs_configuration_response::CfsConfigurationResponse;
19use manta_backend_dispatcher::types::cfs::component::Component as CfsComponent;
20use manta_backend_dispatcher::types::ims::Image;
21
22use crate::server::common::app_context::InfraContext;
23pub use manta_shared::types::api::analysis::BackendSummary;
24pub use manta_shared::types::api::configuration_analysis::ConfigurationAnalysis;
25
26/// Pure linker.
27pub fn build_cache(
28  boot_params: Vec<BootParameters>,
29  images: Vec<Image>,
30) -> Vec<BackendSummary> {
31  // Set of image ids that BSS boot-parameter records currently point
32  // at. An image referenced by BSS is the boot image for at least one
33  // node, so deleting it would break that node's next boot.
34  let bss_boot_image_ids: HashSet<String> = boot_params
35    .iter()
36    .filter_map(BootParameters::try_get_boot_image_id)
37    .collect();
38
39  let mut rows: Vec<BackendSummary> = images
40    .into_iter()
41    .filter_map(|img| {
42      let id = img.id?;
43      let safe_to_delete = !bss_boot_image_ids.contains(&id);
44      Some(BackendSummary {
45        image_id: id,
46        name: img.name,
47        image_created: img.created,
48        configuration_name: img.configuration,
49        safe_to_delete,
50      })
51    })
52    .collect();
53
54  // Primary: image_created ascending (oldest first). Secondary: image_id
55  // ascending for a deterministic tie-break (and as the only ordering when
56  // created is missing on both sides). Images without a created timestamp
57  // sink to the bottom.
58  rows.sort_by(|a, b| {
59    use std::cmp::Ordering;
60    match (a.image_created.as_ref(), b.image_created.as_ref()) {
61      (Some(ac), Some(bc)) => {
62        ac.cmp(bc).then_with(|| a.image_id.cmp(&b.image_id))
63      }
64      (Some(_), None) => Ordering::Less,
65      (None, Some(_)) => Ordering::Greater,
66      (None, None) => a.image_id.cmp(&b.image_id),
67    }
68  });
69  rows
70}
71
72/// Sequence the two upstream fetches and run the pure linker.
73///
74/// Sequenced rather than concurrent: `get_all_bootparameters` returns
75/// a cluster-scale list, and fanning two heavy fetches at the same
76/// upstream is the shape that produced upstream connection-resets on
77/// the configuration variant.
78///
79/// # Errors
80///
81/// - Backend errors from `get_all_bootparameters` (BSS upstream
82///   failure, auth, etc.) propagate as-is.
83/// - Backend / IMS errors from
84///   [`crate::service::image::get_images`] propagate as-is.
85pub async fn get_image_analysis(
86  infra: &InfraContext<'_>,
87  token: &str,
88) -> Result<Vec<BackendSummary>, Error> {
89  tracing::info!("Building image analysis");
90
91  // Deliberately unfiltered: the analysis is a whole-site
92  // image_id -> safe_to_delete map that callers join their own
93  // (possibly filtered) listing against. Narrowing it here would leave
94  // rows outside the filter with an unknown verdict.
95  let images_params = crate::service::image::GetImagesParams {
96    id: None,
97    pattern: None,
98    since: None,
99    until: None,
100    limit: None,
101  };
102
103  let boot_params = infra.backend.get_all_bootparameters(token).await?;
104  let images =
105    crate::service::image::get_images(infra, token, &images_params).await?;
106
107  Ok(build_cache(boot_params, images))
108}
109
110/// Pure linker for the configuration-deletion-safety analysis.
111///
112/// A configuration is flagged unsafe to delete if either:
113/// 1. some CFS component lists it as `desired_config`, or
114/// 2. some IMS image built from it is the boot image of any BSS
115///    boot-parameter record.
116///
117/// The output is one row per configuration in `configs`, sorted by
118/// `last_updated` ascending (oldest first); ties on the timestamp
119/// break by `name` ascending.
120pub fn build_configuration_analysis(
121  mut configs: Vec<CfsConfigurationResponse>,
122  components: Vec<CfsComponent>,
123  boot_params: Vec<BootParameters>,
124  images: Vec<Image>,
125) -> Vec<ConfigurationAnalysis> {
126  // Configs that are some component's desired_config.
127  let mut unsafe_configs: HashSet<String> = components
128    .iter()
129    .filter_map(|c| c.desired_config.clone())
130    .collect();
131
132  // Image_id -> configuration name used to build it.
133  let image_id_to_config: HashMap<String, String> = images
134    .into_iter()
135    .filter_map(|img| match (img.id, img.configuration) {
136      (Some(id), Some(cfg)) => Some((id, cfg)),
137      _ => None,
138    })
139    .collect();
140
141  // Add configs that produced any BSS-referenced image.
142  for bp in &boot_params {
143    if let Some(image_id) = bp.try_get_boot_image_id()
144      && let Some(cfg) = image_id_to_config.get(&image_id)
145    {
146      unsafe_configs.insert(cfg.clone());
147    }
148  }
149
150  configs.sort_by(|a, b| {
151    a.last_updated
152      .cmp(&b.last_updated)
153      .then_with(|| a.name.cmp(&b.name))
154  });
155
156  configs
157    .into_iter()
158    .map(|c| {
159      let safe_to_delete = !unsafe_configs.contains(&c.name);
160      ConfigurationAnalysis {
161        configuration: c,
162        safe_to_delete,
163      }
164    })
165    .collect()
166}
167
168#[cfg(test)]
169mod tests {
170  use super::*;
171
172  fn image(
173    id: &str,
174    name: &str,
175    config: Option<&str>,
176    created: Option<&str>,
177  ) -> Image {
178    Image {
179      id: Some(id.to_string()),
180      name: name.to_string(),
181      created: created.map(String::from),
182      link: None,
183      arch: None,
184      metadata: None,
185      groups: None,
186      base: None,
187      configuration: config.map(String::from),
188    }
189  }
190
191  fn config(name: &str, last_updated: &str) -> CfsConfigurationResponse {
192    CfsConfigurationResponse {
193      name: name.to_string(),
194      last_updated: last_updated.to_string(),
195      layers: vec![],
196      additional_inventory: None,
197    }
198  }
199
200  fn component(id: &str, desired_config: Option<&str>) -> CfsComponent {
201    CfsComponent {
202      id: Some(id.to_string()),
203      state: None,
204      desired_config: desired_config.map(String::from),
205      error_count: None,
206      retry_policy: None,
207      enabled: None,
208      configuration_status: None,
209      tags: None,
210      logs: None,
211    }
212  }
213
214  /// BSS boot-parameter record with a kernel S3 path that points at
215  /// `image_id`. `try_get_boot_image_id` parses `root` (CN) or
216  /// `metal.server` (NCN) from `params`; we set `root` here.
217  fn boot_param_for_image(image_id: &str) -> BootParameters {
218    BootParameters {
219      hosts: vec![],
220      macs: None,
221      nids: None,
222      params: format!("root=s3://boot-images/{image_id}/rootfs"),
223      kernel: format!("s3://boot-images/{image_id}/kernel"),
224      initrd: format!("s3://boot-images/{image_id}/initrd"),
225      cloud_init: None,
226    }
227  }
228
229  // image_id + name + configuration_name come from Image directly.
230  #[test]
231  fn anchors_one_row_per_image_with_built_with_configuration() {
232    let rows = build_cache(
233      vec![],
234      vec![
235        image("img-1", "ncn-1.6-base", Some("ncn-1.6"), None),
236        image("img-2", "compute-1.5", Some("compute-1.5"), None),
237      ],
238    );
239    assert_eq!(rows.len(), 2);
240    assert_eq!(rows[0].image_id, "img-1");
241    assert_eq!(rows[0].name, "ncn-1.6-base");
242    assert_eq!(rows[0].configuration_name.as_deref(), Some("ncn-1.6"));
243    assert_eq!(rows[1].image_id, "img-2");
244  }
245
246  // Orphan image: nothing references it. Row exists, `safe_to_delete`
247  // is true, every Option column is None.
248  #[test]
249  fn orphan_image_is_safe_to_delete() {
250    let rows = build_cache(vec![], vec![image("img-1", "orphan", None, None)]);
251    assert_eq!(rows.len(), 1);
252    let row = &rows[0];
253    assert_eq!(row.image_id, "img-1");
254    assert!(row.image_created.is_none());
255    assert!(row.configuration_name.is_none());
256    assert!(row.safe_to_delete);
257  }
258
259  // When no image has a created timestamp, sort falls back to image_id
260  // ascending so output stays deterministic across runs.
261  #[test]
262  fn rows_with_no_created_timestamp_fall_back_to_image_id_asc() {
263    let rows = build_cache(
264      vec![],
265      vec![
266        image("img-z", "z", None, None),
267        image("img-a", "a", None, None),
268        image("img-m", "m", None, None),
269      ],
270    );
271    let ids: Vec<&str> = rows.iter().map(|r| r.image_id.as_str()).collect();
272    assert_eq!(ids, vec!["img-a", "img-m", "img-z"]);
273  }
274
275  // Primary sort: image_created ascending (oldest first). Images without
276  // a created timestamp sink to the bottom; ties on created (or both None)
277  // break by image_id ascending.
278  #[test]
279  fn rows_are_sorted_by_image_created_ascending() {
280    let rows = build_cache(
281      vec![],
282      vec![
283        image("img-old", "old", None, Some("2024-01-01T00:00:00Z")),
284        image("img-newest", "newest", None, Some("2026-06-02T00:00:00Z")),
285        image("img-undated-z", "undated-z", None, None),
286        image("img-middle", "middle", None, Some("2026-06-01T00:00:00Z")),
287        image("img-undated-a", "undated-a", None, None),
288      ],
289    );
290    let ids: Vec<&str> = rows.iter().map(|r| r.image_id.as_str()).collect();
291    assert_eq!(
292      ids,
293      vec![
294        "img-old",       // 2024-01-01
295        "img-middle",    // 2026-06-01
296        "img-newest",    // 2026-06-02
297        "img-undated-a", // None, id asc tie-break
298        "img-undated-z", // None, id asc tie-break
299      ]
300    );
301  }
302
303  #[test]
304  fn empty_input_yields_empty_cache() {
305    let rows = build_cache(vec![], vec![]);
306    assert!(rows.is_empty());
307  }
308
309  // Image referenced as the boot image of a BSS record is unsafe to
310  // delete; the unreferenced sibling stays safe.
311  #[test]
312  fn bss_referenced_image_is_unsafe_to_delete() {
313    let rows = build_cache(
314      vec![boot_param_for_image("img-booted")],
315      vec![
316        image("img-booted", "in-use", None, None),
317        image("img-orphan", "spare", None, None),
318      ],
319    );
320    let booted = rows.iter().find(|r| r.image_id == "img-booted").unwrap();
321    let orphan = rows.iter().find(|r| r.image_id == "img-orphan").unwrap();
322    assert!(!booted.safe_to_delete);
323    assert!(orphan.safe_to_delete);
324  }
325
326  // ------------------------------------------------------------------
327  // build_configuration_analysis
328  // ------------------------------------------------------------------
329
330  #[test]
331  fn configuration_analysis_orphan_config_is_safe_to_delete() {
332    let rows = build_configuration_analysis(
333      vec![config("orphan", "2025-01-01T00:00:00Z")],
334      vec![],
335      vec![],
336      vec![],
337    );
338    assert_eq!(rows.len(), 1);
339    assert_eq!(rows[0].configuration.name, "orphan");
340    assert_eq!(rows[0].configuration.last_updated, "2025-01-01T00:00:00Z");
341    assert!(rows[0].safe_to_delete);
342  }
343
344  #[test]
345  fn configuration_analysis_desired_by_component_is_unsafe() {
346    let rows = build_configuration_analysis(
347      vec![
348        config("desired", "2025-01-01T00:00:00Z"),
349        config("nobody-cares", "2025-01-02T00:00:00Z"),
350      ],
351      vec![component("x1000c0s0b0n0", Some("desired"))],
352      vec![],
353      vec![],
354    );
355    let desired = rows
356      .iter()
357      .find(|r| r.configuration.name == "desired")
358      .unwrap();
359    let other = rows
360      .iter()
361      .find(|r| r.configuration.name == "nobody-cares")
362      .unwrap();
363    assert!(!desired.safe_to_delete);
364    assert!(other.safe_to_delete);
365  }
366
367  #[test]
368  fn configuration_analysis_bss_referenced_image_makes_config_unsafe() {
369    let rows = build_configuration_analysis(
370      vec![
371        config("boot-config", "2025-01-01T00:00:00Z"),
372        config("nobody-cares", "2025-01-02T00:00:00Z"),
373      ],
374      vec![],
375      vec![boot_param_for_image("img-bsst")],
376      vec![image("img-bsst", "boot-img", Some("boot-config"), None)],
377    );
378    let boot = rows
379      .iter()
380      .find(|r| r.configuration.name == "boot-config")
381      .unwrap();
382    let other = rows
383      .iter()
384      .find(|r| r.configuration.name == "nobody-cares")
385      .unwrap();
386    assert!(!boot.safe_to_delete);
387    assert!(other.safe_to_delete);
388  }
389
390  #[test]
391  fn configuration_analysis_bss_pointing_at_unknown_image_does_not_flag() {
392    // BSS references an image the IMS listing does not return.
393    // Without that image we can't resolve to a configuration, so
394    // every config stays safe.
395    let rows = build_configuration_analysis(
396      vec![config("c1", "2025-01-01T00:00:00Z")],
397      vec![],
398      vec![boot_param_for_image("missing-image")],
399      vec![],
400    );
401    assert!(rows[0].safe_to_delete);
402  }
403
404  #[test]
405  fn configuration_analysis_rows_sorted_by_last_updated_asc_then_name() {
406    let rows = build_configuration_analysis(
407      vec![
408        config("z", "2025-06-01T00:00:00Z"),
409        config("a", "2024-01-01T00:00:00Z"),
410        config("b", "2025-06-01T00:00:00Z"),
411      ],
412      vec![],
413      vec![],
414      vec![],
415    );
416    let names: Vec<&str> =
417      rows.iter().map(|r| r.configuration.name.as_str()).collect();
418    assert_eq!(names, vec!["a", "b", "z"]); // oldest first; ties by name asc
419  }
420
421  #[test]
422  fn configuration_analysis_components_without_desired_config_are_ignored() {
423    let rows = build_configuration_analysis(
424      vec![config("c1", "2025-01-01T00:00:00Z")],
425      vec![component("x1000c0s0b0n0", None)],
426      vec![],
427      vec![],
428    );
429    assert!(rows[0].safe_to_delete);
430  }
431}