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