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/// Sequenced rather than concurrent: `get_all_bootparameters` returns
74/// a cluster-scale list, and fanning two heavy fetches at the same
75/// upstream is the shape that produced upstream connection-resets on
76/// the configuration variant.
77pub async fn get_image_analysis(
78  infra: &InfraContext<'_>,
79  token: &str,
80) -> Result<Vec<BackendSummary>, Error> {
81  tracing::info!("Building image analysis");
82
83  let images_params = crate::service::image::GetImagesParams {
84    id: None,
85    pattern: None,
86    limit: None,
87  };
88
89  let boot_params = infra.backend.get_all_bootparameters(token).await?;
90  let images =
91    crate::service::image::get_images(infra, token, &images_params).await?;
92
93  Ok(build_cache(boot_params, images))
94}
95
96/// Pure linker for the configuration-deletion-safety analysis.
97///
98/// A configuration is flagged unsafe to delete if either:
99/// 1. some CFS component lists it as `desired_config`, or
100/// 2. some IMS image built from it is the boot image of any BSS
101///    boot-parameter record.
102///
103/// The output is one row per configuration in `configs`, sorted by
104/// `last_updated` ascending (oldest first); ties on the timestamp
105/// break by `name` ascending.
106pub fn build_configuration_analysis(
107  mut configs: Vec<CfsConfigurationResponse>,
108  components: Vec<CfsComponent>,
109  boot_params: Vec<BootParameters>,
110  images: Vec<Image>,
111) -> Vec<ConfigurationAnalysis> {
112  // Configs that are some component's desired_config.
113  let mut unsafe_configs: HashSet<String> = components
114    .iter()
115    .filter_map(|c| c.desired_config.clone())
116    .collect();
117
118  // Image_id -> configuration name used to build it.
119  let image_id_to_config: HashMap<String, String> = images
120    .into_iter()
121    .filter_map(|img| match (img.id, img.configuration) {
122      (Some(id), Some(cfg)) => Some((id, cfg)),
123      _ => None,
124    })
125    .collect();
126
127  // Add configs that produced any BSS-referenced image.
128  for bp in &boot_params {
129    if let Some(image_id) = bp.try_get_boot_image_id() {
130      if let Some(cfg) = image_id_to_config.get(&image_id) {
131        unsafe_configs.insert(cfg.clone());
132      }
133    }
134  }
135
136  configs.sort_by(|a, b| {
137    a.last_updated
138      .cmp(&b.last_updated)
139      .then_with(|| a.name.cmp(&b.name))
140  });
141
142  configs
143    .into_iter()
144    .map(|c| {
145      let safe_to_delete = !unsafe_configs.contains(&c.name);
146      ConfigurationAnalysis {
147        configuration: c,
148        safe_to_delete,
149      }
150    })
151    .collect()
152}
153
154#[cfg(test)]
155mod tests {
156  use super::*;
157
158  fn image(
159    id: &str,
160    name: &str,
161    config: Option<&str>,
162    created: Option<&str>,
163  ) -> Image {
164    Image {
165      id: Some(id.to_string()),
166      name: name.to_string(),
167      created: created.map(String::from),
168      link: None,
169      arch: None,
170      metadata: None,
171      groups: None,
172      base: None,
173      configuration: config.map(String::from),
174    }
175  }
176
177  fn config(name: &str, last_updated: &str) -> CfsConfigurationResponse {
178    CfsConfigurationResponse {
179      name: name.to_string(),
180      last_updated: last_updated.to_string(),
181      layers: vec![],
182      additional_inventory: None,
183    }
184  }
185
186  fn component(id: &str, desired_config: Option<&str>) -> CfsComponent {
187    CfsComponent {
188      id: Some(id.to_string()),
189      state: None,
190      desired_config: desired_config.map(String::from),
191      error_count: None,
192      retry_policy: None,
193      enabled: None,
194      configuration_status: None,
195      tags: None,
196      logs: None,
197    }
198  }
199
200  /// BSS boot-parameter record with a kernel S3 path that points at
201  /// `image_id`. `try_get_boot_image_id` parses `root` (CN) or
202  /// `metal.server` (NCN) from `params`; we set `root` here.
203  fn boot_param_for_image(image_id: &str) -> BootParameters {
204    BootParameters {
205      hosts: vec![],
206      macs: None,
207      nids: None,
208      params: format!("root=s3://boot-images/{image_id}/rootfs"),
209      kernel: format!("s3://boot-images/{image_id}/kernel"),
210      initrd: format!("s3://boot-images/{image_id}/initrd"),
211      cloud_init: None,
212    }
213  }
214
215  // image_id + name + configuration_name come from Image directly.
216  #[test]
217  fn anchors_one_row_per_image_with_built_with_configuration() {
218    let rows = build_cache(
219      vec![],
220      vec![
221        image("img-1", "ncn-1.6-base", Some("ncn-1.6"), None),
222        image("img-2", "compute-1.5", Some("compute-1.5"), None),
223      ],
224    );
225    assert_eq!(rows.len(), 2);
226    assert_eq!(rows[0].image_id, "img-1");
227    assert_eq!(rows[0].name, "ncn-1.6-base");
228    assert_eq!(rows[0].configuration_name.as_deref(), Some("ncn-1.6"));
229    assert_eq!(rows[1].image_id, "img-2");
230  }
231
232  // Orphan image: nothing references it. Row exists, `safe_to_delete`
233  // is true, every Option column is None.
234  #[test]
235  fn orphan_image_is_safe_to_delete() {
236    let rows = build_cache(vec![], vec![image("img-1", "orphan", None, None)]);
237    assert_eq!(rows.len(), 1);
238    let row = &rows[0];
239    assert_eq!(row.image_id, "img-1");
240    assert!(row.image_created.is_none());
241    assert!(row.configuration_name.is_none());
242    assert!(row.safe_to_delete);
243  }
244
245  // When no image has a created timestamp, sort falls back to image_id
246  // ascending so output stays deterministic across runs.
247  #[test]
248  fn rows_with_no_created_timestamp_fall_back_to_image_id_asc() {
249    let rows = build_cache(
250      vec![],
251      vec![
252        image("img-z", "z", None, None),
253        image("img-a", "a", None, None),
254        image("img-m", "m", None, None),
255      ],
256    );
257    let ids: Vec<&str> = rows.iter().map(|r| r.image_id.as_str()).collect();
258    assert_eq!(ids, vec!["img-a", "img-m", "img-z"]);
259  }
260
261  // Primary sort: image_created ascending (oldest first). Images without
262  // a created timestamp sink to the bottom; ties on created (or both None)
263  // break by image_id ascending.
264  #[test]
265  fn rows_are_sorted_by_image_created_ascending() {
266    let rows = build_cache(
267      vec![],
268      vec![
269        image("img-old", "old", None, Some("2024-01-01T00:00:00Z")),
270        image("img-newest", "newest", None, Some("2026-06-02T00:00:00Z")),
271        image("img-undated-z", "undated-z", None, None),
272        image("img-middle", "middle", None, Some("2026-06-01T00:00:00Z")),
273        image("img-undated-a", "undated-a", None, None),
274      ],
275    );
276    let ids: Vec<&str> = rows.iter().map(|r| r.image_id.as_str()).collect();
277    assert_eq!(
278      ids,
279      vec![
280        "img-old",       // 2024-01-01
281        "img-middle",    // 2026-06-01
282        "img-newest",    // 2026-06-02
283        "img-undated-a", // None, id asc tie-break
284        "img-undated-z", // None, id asc tie-break
285      ]
286    );
287  }
288
289  #[test]
290  fn empty_input_yields_empty_cache() {
291    let rows = build_cache(vec![], vec![]);
292    assert!(rows.is_empty());
293  }
294
295  // Image referenced as the boot image of a BSS record is unsafe to
296  // delete; the unreferenced sibling stays safe.
297  #[test]
298  fn bss_referenced_image_is_unsafe_to_delete() {
299    let rows = build_cache(
300      vec![boot_param_for_image("img-booted")],
301      vec![
302        image("img-booted", "in-use", None, None),
303        image("img-orphan", "spare", None, None),
304      ],
305    );
306    let booted = rows.iter().find(|r| r.image_id == "img-booted").unwrap();
307    let orphan = rows.iter().find(|r| r.image_id == "img-orphan").unwrap();
308    assert!(!booted.safe_to_delete);
309    assert!(orphan.safe_to_delete);
310  }
311
312  // ------------------------------------------------------------------
313  // build_configuration_analysis
314  // ------------------------------------------------------------------
315
316  #[test]
317  fn configuration_analysis_orphan_config_is_safe_to_delete() {
318    let rows = build_configuration_analysis(
319      vec![config("orphan", "2025-01-01T00:00:00Z")],
320      vec![],
321      vec![],
322      vec![],
323    );
324    assert_eq!(rows.len(), 1);
325    assert_eq!(rows[0].configuration.name, "orphan");
326    assert_eq!(rows[0].configuration.last_updated, "2025-01-01T00:00:00Z");
327    assert!(rows[0].safe_to_delete);
328  }
329
330  #[test]
331  fn configuration_analysis_desired_by_component_is_unsafe() {
332    let rows = build_configuration_analysis(
333      vec![
334        config("desired", "2025-01-01T00:00:00Z"),
335        config("nobody-cares", "2025-01-02T00:00:00Z"),
336      ],
337      vec![component("x1000c0s0b0n0", Some("desired"))],
338      vec![],
339      vec![],
340    );
341    let desired = rows
342      .iter()
343      .find(|r| r.configuration.name == "desired")
344      .unwrap();
345    let other = rows
346      .iter()
347      .find(|r| r.configuration.name == "nobody-cares")
348      .unwrap();
349    assert!(!desired.safe_to_delete);
350    assert!(other.safe_to_delete);
351  }
352
353  #[test]
354  fn configuration_analysis_bss_referenced_image_makes_config_unsafe() {
355    let rows = build_configuration_analysis(
356      vec![
357        config("boot-config", "2025-01-01T00:00:00Z"),
358        config("nobody-cares", "2025-01-02T00:00:00Z"),
359      ],
360      vec![],
361      vec![boot_param_for_image("img-bsst")],
362      vec![image("img-bsst", "boot-img", Some("boot-config"), None)],
363    );
364    let boot = rows
365      .iter()
366      .find(|r| r.configuration.name == "boot-config")
367      .unwrap();
368    let other = rows
369      .iter()
370      .find(|r| r.configuration.name == "nobody-cares")
371      .unwrap();
372    assert!(!boot.safe_to_delete);
373    assert!(other.safe_to_delete);
374  }
375
376  #[test]
377  fn configuration_analysis_bss_pointing_at_unknown_image_does_not_flag() {
378    // BSS references an image the IMS listing does not return.
379    // Without that image we can't resolve to a configuration, so
380    // every config stays safe.
381    let rows = build_configuration_analysis(
382      vec![config("c1", "2025-01-01T00:00:00Z")],
383      vec![],
384      vec![boot_param_for_image("missing-image")],
385      vec![],
386    );
387    assert!(rows[0].safe_to_delete);
388  }
389
390  #[test]
391  fn configuration_analysis_rows_sorted_by_last_updated_asc_then_name() {
392    let rows = build_configuration_analysis(
393      vec![
394        config("z", "2025-06-01T00:00:00Z"),
395        config("a", "2024-01-01T00:00:00Z"),
396        config("b", "2025-06-01T00:00:00Z"),
397      ],
398      vec![],
399      vec![],
400      vec![],
401    );
402    let names: Vec<&str> =
403      rows.iter().map(|r| r.configuration.name.as_str()).collect();
404    assert_eq!(names, vec!["a", "b", "z"]); // oldest first; ties by name asc
405  }
406
407  #[test]
408  fn configuration_analysis_components_without_desired_config_are_ignored() {
409    let rows = build_configuration_analysis(
410      vec![config("c1", "2025-01-01T00:00:00Z")],
411      vec![component("x1000c0s0b0n0", None)],
412      vec![],
413      vec![],
414    );
415    assert!(rows[0].safe_to_delete);
416  }
417}