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 {
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
104pub 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 let mut unsafe_configs: HashSet<String> = components
122 .iter()
123 .filter_map(|c| c.desired_config.clone())
124 .collect();
125
126 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 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 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 #[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 #[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 #[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 #[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", "img-middle", "img-newest", "img-undated-a", "img-undated-z", ]
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 #[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 #[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 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"]); }
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}