1use 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
26pub fn build_cache(
28 boot_params: Vec<BootParameters>,
29 images: Vec<Image>,
30) -> Vec<BackendSummary> {
31 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 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
72pub 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
96pub 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 let mut unsafe_configs: HashSet<String> = components
114 .iter()
115 .filter_map(|c| c.desired_config.clone())
116 .collect();
117
118 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 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 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 #[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 #[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 #[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 #[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", "img-middle", "img-newest", "img-undated-a", "img-undated-z", ]
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 #[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 #[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 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"]); }
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}