Skip to content

Commit 50b9e82

Browse files
committed
feat: SBOM pruning functionality
1 parent 4510ec4 commit 50b9e82

File tree

7 files changed

+383
-49
lines changed

7 files changed

+383
-49
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

etc/trustify-cli/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ dotenvy = { workspace = true }
1414
futures = { workspace = true }
1515
indicatif = { workspace = true }
1616
reqwest = { workspace = true, features = ["blocking", "form", "json", "query"] }
17+
log = { workspace = true }
1718
serde = { workspace = true , features = ["derive"] }
1819
serde_json = { workspace = true }
1920
thiserror = { workspace = true }
2021
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] }
21-
22+
chrono = { workspace = true, features = ["serde", "clock"] }

etc/trustify-cli/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ trustify sbom duplicates delete
4343
- [`sbom delete`](#sbom-delete)
4444
- [`sbom duplicates find`](#sbom-duplicates-find)
4545
- [`sbom duplicates delete`](#sbom-duplicates-delete)
46+
- [`sbom prune`](#sbom-prune)
47+
4648
- [API Reference](#api-reference)
4749
- [License](#license)
4850

@@ -175,3 +177,55 @@ trustify sbom duplicates delete # Delete all duplicates
175177
trustify sbom duplicates delete -j 16 # Faster with 16 concurrent requests
176178
trustify sbom duplicates delete --input out.json # Use custom input file
177179
```
180+
181+
---
182+
183+
### `sbom prune`
184+
185+
Prune SBOMs based on various criteria like age, labels, or keeping only the latest versions. Always preview with `--dry-run` first!
186+
187+
```bash
188+
trustify sbom prune --dry-run # Preview what will be pruned
189+
trustify sbom prune --older-than 90 # Delete SBOMs older than 90 days
190+
trustify sbom prune --published-before 2026-01-15T10:30:45Z # Delete SBOMs published before thespecified date
191+
trustify sbom prune --label type=spdx --label importer=run # Delete SBOMs with specific labels
192+
trustify sbom prune --keep-latest 5 # Keep only 5 most recent per document ID
193+
trustify sbom prune --query "name=my-app" # Custom query filter
194+
trustify sbom prune --limit 1000 # Limit results and increase concurrency
195+
trustify sbom prune --output results.json --quiet # Save results to file, suppress output
196+
```
197+
198+
**Output file format:**
199+
200+
```json
201+
{
202+
"deleted": [
203+
{
204+
"sbom_id": "urn:uuid:019c4a3f-dc4e-7383-8154-248b6fde0bf0",
205+
"document_id": "https://security.access.redhat.com/data/sbom/v1/spdx/rhacs-4.9/2026-02-10/789b2d0e8ca41796396188ed277cfc486d11e01c0a38847031afed71ac629729"
206+
}
207+
],
208+
"deleted_total": 1,
209+
"skipped": [
210+
{
211+
"sbom_id": "urn:uuid:019c4a3f-a277-7882-a7c0-46cc40e6d56d",
212+
"document_id": "https://security.access.redhat.com/data/sbom/v1/spdx/rhcl-1/2026-02-10/03e360634a6e4c341c198cd526c16f2d2d5a87c24a4d47a224c6234976254272"
213+
}
214+
],
215+
"skipped_total": 1,
216+
"failed": [
217+
{
218+
"sbom_id": "urn:uuid:019c4a37-0588-7623-bcea-c86b1c934e7f",
219+
"document_id": "https://security.access.redhat.com/data/sbom/v1/spdx/rhel-9.7.z/2026-02-10/c581247cac636be448ba6a0a931f34a191e626f1d9251d30bb50364b5eee574d",
220+
"error": "HTTP 408: Server timeout"
221+
},
222+
{
223+
"sbom_id": "urn:uuid:019c4a38-4212-77b3-914e-ed1c897b32d1",
224+
"document_id": "https://security.access.redhat.com/data/sbom/v1/spdx/rhel-9.6.z/2026-02-10/a149c656d9084b579939ebd4b30b71b3ca5b8ab28c0e39aea00703b274092ea1",
225+
"error": "HTTP 408: Server timeout"
226+
},
227+
],
228+
"failed_total": 2,
229+
"total": 4
230+
}
231+
```

etc/trustify-cli/src/api/client.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
use std::sync::Arc;
2-
use std::time::Duration;
1+
use std::{sync::Arc, time::Duration};
32

43
use indicatif::style::TemplateError;
54
use reqwest::{Client, RequestBuilder, StatusCode};
65
use thiserror::Error;
7-
use tokio::sync::RwLock;
8-
use tokio::time::sleep;
6+
use tokio::{sync::RwLock, time::sleep};
97

108
const MAX_RETRIES: u32 = 3;
119
const RETRY_DELAY_MS: u64 = 1000;

etc/trustify-cli/src/api/sbom.rs

Lines changed: 187 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1-
use std::collections::HashMap;
2-
use std::fs::File;
3-
use std::io::{BufReader, Write};
4-
use std::path::Path;
5-
use std::sync::Arc;
6-
use std::sync::atomic::{AtomicU32, Ordering};
7-
8-
use futures::future::join_all;
9-
use futures::stream::{self, StreamExt};
1+
use std::{
2+
collections::HashMap,
3+
fs::File,
4+
io::{BufReader, Write},
5+
path::Path,
6+
sync::Arc,
7+
};
8+
9+
use chrono::{DateTime, Duration, Local};
10+
use futures::{
11+
future::join_all,
12+
stream::{self, StreamExt},
13+
};
1014
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
15+
use log;
1116
use serde::{Deserialize, Serialize};
1217
use serde_json::Value;
1318
use tokio::sync::Mutex;
@@ -35,6 +40,25 @@ pub struct FindDuplicatesParams {
3540
pub concurrency: usize,
3641
}
3742

43+
/// Parameters for pruning SBOMs
44+
#[derive(Default, Serialize)]
45+
pub struct PruneParams {
46+
#[serde(skip_serializing_if = "Option::is_none")]
47+
pub q: Option<String>,
48+
#[serde(skip_serializing_if = "Option::is_none")]
49+
pub limit: Option<u32>,
50+
#[serde(skip_serializing_if = "Option::is_none")]
51+
pub published_before: Option<DateTime<Local>>,
52+
#[serde(skip_serializing_if = "Option::is_none")]
53+
pub older_than: Option<i64>,
54+
#[serde(skip_serializing_if = "Option::is_none")]
55+
pub label: Option<Vec<String>>,
56+
#[serde(skip_serializing_if = "Option::is_none")]
57+
pub keep_latest: Option<u32>,
58+
pub dry_run: bool,
59+
pub concurrency: usize,
60+
}
61+
3862
/// SBOM entry for duplicate detection
3963
#[derive(Debug, Clone)]
4064
struct SbomEntry {
@@ -357,24 +381,135 @@ pub async fn delete_by_query(
357381
);
358382
}
359383
return Ok(DeleteResult {
360-
deleted: 0,
361-
skipped: 0,
362-
failed: 0,
384+
deleted: vec![],
385+
deleted_total: 0,
386+
skipped: vec![],
387+
skipped_total: 0,
388+
failed: vec![],
389+
failed_total: 0,
363390
total,
364391
});
365392
}
366393

367394
delete_list(client, entries, concurrency).await
368395
}
369396

370-
/// Result of deleting duplicates
397+
/// Prune SBOMs based on the given parameters
398+
pub async fn prune(client: &ApiClient, params: &PruneParams) -> Result<DeleteResult, ApiError> {
399+
// Build query parameters for listing SBOMs to prune
400+
let mut query = params.q.as_deref().unwrap_or("").to_string();
401+
if let Some(d) = params.published_before.as_ref() {
402+
query.push_str(&format!("&published<{}", d.to_rfc3339()));
403+
}
404+
405+
if let Some(older_than) = params.older_than {
406+
let older_than_time = Local::now() - Duration::days(older_than);
407+
query.push_str(&format!("&ingested<{}", older_than_time.to_rfc3339()));
408+
}
409+
410+
if let Some(labels) = &params.label {
411+
for l in labels.iter() {
412+
query.push_str(&format!("&labels:{}", l));
413+
}
414+
}
415+
416+
let (offset, sort) = match params.keep_latest {
417+
Some(v) => (Some(v), Some("ingested:desc".to_string())),
418+
None => (None, None),
419+
};
420+
let list_params = ListParams {
421+
q: Some(query),
422+
limit: params.limit,
423+
offset,
424+
sort,
425+
};
426+
427+
log::info!(
428+
"Pruning SBOMs with query: {}, offset: {:?}, sort: {:?}",
429+
list_params.q.as_deref().unwrap_or(""),
430+
list_params.offset,
431+
list_params.sort
432+
);
433+
434+
// Get list of SBOMs matching the criteria
435+
let response = list(client, &list_params).await?;
436+
let parsed: Value = serde_json::from_str(&response)
437+
.map_err(|e| ApiError::InternalError(format!("Failed to parse response: {}", e)))?;
438+
439+
let items = parsed
440+
.get("items")
441+
.and_then(|v| v.as_array())
442+
.ok_or_else(|| ApiError::InternalError("No items in response".to_string()))?;
443+
444+
let total = items.len() as u32;
445+
446+
// Convert items to delete entries
447+
let entries: Vec<DeleteEntry> = items
448+
.iter()
449+
.filter_map(|item| {
450+
let id = item.get("id").and_then(|v| v.as_str())?;
451+
let document_id = item
452+
.get("document_id")
453+
.and_then(|v| v.as_str())
454+
.unwrap_or("unknown");
455+
Some(DeleteEntry {
456+
id: id.to_string(),
457+
document_id: document_id.to_string(),
458+
})
459+
})
460+
.collect();
461+
462+
// If dry run, just return the count without deleting
463+
if params.dry_run {
464+
return Ok(DeleteResult {
465+
deleted: vec![],
466+
deleted_total: 0,
467+
skipped: vec![],
468+
skipped_total: 0,
469+
failed: vec![],
470+
failed_total: 0,
471+
total,
472+
});
473+
}
474+
475+
// Perform the actual deletion
476+
delete_list(client, entries, params.concurrency).await
477+
}
478+
479+
#[derive(Debug, Clone, Serialize)]
480+
/// Result of deleting SBOMs
371481
pub struct DeleteResult {
372-
pub deleted: u32,
373-
pub skipped: u32,
374-
pub failed: u32,
482+
pub deleted: Vec<DeletedResult>,
483+
pub deleted_total: u32,
484+
pub skipped: Vec<SkippedResult>,
485+
pub skipped_total: u32,
486+
pub failed: Vec<FailedResult>,
487+
pub failed_total: u32,
375488
pub total: u32,
376489
}
377490

491+
#[derive(Debug, Clone, Serialize)]
492+
/// Successfully deleted SBOM
493+
pub struct DeletedResult {
494+
pub sbom_id: String,
495+
pub document_id: String,
496+
}
497+
498+
#[derive(Debug, Clone, Serialize)]
499+
/// Skipped SBOM (not found)
500+
pub struct SkippedResult {
501+
pub sbom_id: String,
502+
pub document_id: String,
503+
}
504+
505+
#[derive(Debug, Clone, Serialize)]
506+
/// Failed to delete SBOM
507+
pub struct FailedResult {
508+
pub sbom_id: String,
509+
pub document_id: String,
510+
pub error: String,
511+
}
512+
378513
/// Entry to delete with its document_id for logging
379514
#[derive(Clone)]
380515
pub struct DeleteEntry {
@@ -421,7 +556,7 @@ pub async fn delete_list(
421556
let total = entries.len() as u32;
422557

423558
eprintln!(
424-
"Deleting {} duplicates with {} concurrent requests...\n",
559+
"Deleting {} sboms with {} concurrent requests...\n",
425560
total, concurrency
426561
);
427562

@@ -432,9 +567,9 @@ pub async fn delete_list(
432567
.progress_chars("█▓░"),
433568
);
434569

435-
let deleted = Arc::new(AtomicU32::new(0));
436-
let skipped = Arc::new(AtomicU32::new(0));
437-
let failed = Arc::new(AtomicU32::new(0));
570+
let deleted = Arc::new(Mutex::new(Vec::new()));
571+
let skipped = Arc::new(Mutex::new(Vec::new()));
572+
let failed = Arc::new(Mutex::new(Vec::new()));
438573

439574
stream::iter(entries)
440575
.for_each_concurrent(concurrency, |entry| {
@@ -446,13 +581,26 @@ pub async fn delete_list(
446581
async move {
447582
match delete(&client, &entry.id).await {
448583
Ok(_) => {
449-
deleted.fetch_add(1, Ordering::Relaxed);
584+
let mut deleted_list = deleted.lock().await;
585+
deleted_list.push(DeletedResult {
586+
sbom_id: entry.id.clone(),
587+
document_id: entry.document_id.clone(),
588+
});
450589
}
451590
Err(ApiError::NotFound(_)) => {
452-
skipped.fetch_add(1, Ordering::Relaxed);
591+
let mut skipped_list = skipped.lock().await;
592+
skipped_list.push(SkippedResult {
593+
sbom_id: entry.id.clone(),
594+
document_id: entry.document_id.clone(),
595+
});
453596
}
454597
Err(e) => {
455-
failed.fetch_add(1, Ordering::Relaxed);
598+
let mut failed_list = failed.lock().await;
599+
failed_list.push(FailedResult {
600+
sbom_id: entry.id.clone(),
601+
document_id: entry.document_id.clone(),
602+
error: e.to_string(),
603+
});
456604
progress.println(format!(
457605
"Failed to delete {} (document_id: {}): {}",
458606
entry.id, entry.document_id, e
@@ -466,10 +614,17 @@ pub async fn delete_list(
466614

467615
progress.finish_with_message("complete");
468616

617+
let deleted_list = deleted.lock().await;
618+
let skipped_list = skipped.lock().await;
619+
let failed_list = failed.lock().await;
620+
469621
Ok(DeleteResult {
470-
deleted: deleted.load(Ordering::Relaxed),
471-
skipped: skipped.load(Ordering::Relaxed),
472-
failed: failed.load(Ordering::Relaxed),
622+
deleted: deleted_list.clone(),
623+
deleted_total: deleted_list.len() as u32,
624+
skipped: skipped_list.clone(),
625+
skipped_total: skipped_list.len() as u32,
626+
failed: failed_list.clone(),
627+
failed_total: failed_list.len() as u32,
473628
total,
474629
})
475630
}
@@ -492,9 +647,12 @@ pub async fn delete_duplicates(
492647
);
493648
}
494649
return Ok(DeleteResult {
495-
deleted: 0,
496-
skipped: 0,
497-
failed: 0,
650+
deleted: vec![],
651+
deleted_total: 0,
652+
skipped: vec![],
653+
skipped_total: 0,
654+
failed: vec![],
655+
failed_total: 0,
498656
total,
499657
});
500658
}

0 commit comments

Comments
 (0)