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(
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 {
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
110pub 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 let mut unsafe_configs: HashSet<String> = components
128 .iter()
129 .filter_map(|c| c.desired_config.clone())
130 .collect();
131
132 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 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 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 #[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 #[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 #[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 #[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", "img-middle", "img-newest", "img-undated-a", "img-undated-z", ]
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 #[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 #[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 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"]); }
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}