Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion etc/trustify-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ dotenvy = { workspace = true }
futures = { workspace = true }
indicatif = { workspace = true }
reqwest = { workspace = true, features = ["blocking", "form", "json", "query"] }
log = { workspace = true }
serde = { workspace = true , features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] }

chrono = { workspace = true, features = ["serde", "clock"] }
54 changes: 54 additions & 0 deletions etc/trustify-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ trustify sbom duplicates delete
- [`sbom delete`](#sbom-delete)
- [`sbom duplicates find`](#sbom-duplicates-find)
- [`sbom duplicates delete`](#sbom-duplicates-delete)
- [`sbom prune`](#sbom-prune)

- [API Reference](#api-reference)
- [License](#license)

Expand Down Expand Up @@ -175,3 +177,55 @@ trustify sbom duplicates delete # Delete all duplicates
trustify sbom duplicates delete -j 16 # Faster with 16 concurrent requests
trustify sbom duplicates delete --input out.json # Use custom input file
```

---

### `sbom prune`

Prune SBOMs based on various criteria like age, labels, or keeping only the latest versions. Always preview with `--dry-run` first!

```bash
trustify sbom prune --dry-run # Preview what will be pruned
trustify sbom prune --older-than 90 # Delete SBOMs older than 90 days
trustify sbom prune --published-before 2026-01-15T10:30:45Z # Delete SBOMs published before thespecified date
trustify sbom prune --label type=spdx --label importer=run # Delete SBOMs with specific labels
trustify sbom prune --keep-latest 5 # Keep only 5 most recent per document ID
trustify sbom prune --query "name=my-app" # Custom query filter
trustify sbom prune --limit 1000 # Limit results and increase concurrency
trustify sbom prune --output results.json --quiet # Save results to file, suppress output
```

**Output file format:**

```json
{
"deleted": [
{
"sbom_id": "urn:uuid:019c4a3f-dc4e-7383-8154-248b6fde0bf0",
"document_id": "https://security.access.redhat.com/data/sbom/v1/spdx/rhacs-4.9/2026-02-10/789b2d0e8ca41796396188ed277cfc486d11e01c0a38847031afed71ac629729"
}
],
"deleted_total": 1,
"skipped": [
{
"sbom_id": "urn:uuid:019c4a3f-a277-7882-a7c0-46cc40e6d56d",
"document_id": "https://security.access.redhat.com/data/sbom/v1/spdx/rhcl-1/2026-02-10/03e360634a6e4c341c198cd526c16f2d2d5a87c24a4d47a224c6234976254272"
}
],
"skipped_total": 1,
"failed": [
{
"sbom_id": "urn:uuid:019c4a37-0588-7623-bcea-c86b1c934e7f",
"document_id": "https://security.access.redhat.com/data/sbom/v1/spdx/rhel-9.7.z/2026-02-10/c581247cac636be448ba6a0a931f34a191e626f1d9251d30bb50364b5eee574d",
"error": "HTTP 408: Server timeout"
},
{
"sbom_id": "urn:uuid:019c4a38-4212-77b3-914e-ed1c897b32d1",
"document_id": "https://security.access.redhat.com/data/sbom/v1/spdx/rhel-9.6.z/2026-02-10/a149c656d9084b579939ebd4b30b71b3ca5b8ab28c0e39aea00703b274092ea1",
"error": "HTTP 408: Server timeout"
},
],
"failed_total": 2,
"total": 4
}
```
6 changes: 2 additions & 4 deletions etc/trustify-cli/src/api/client.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
use std::sync::Arc;
use std::time::Duration;
use std::{sync::Arc, time::Duration};

use indicatif::style::TemplateError;
use reqwest::{Client, RequestBuilder, StatusCode};
use thiserror::Error;
use tokio::sync::RwLock;
use tokio::time::sleep;
use tokio::{sync::RwLock, time::sleep};

const MAX_RETRIES: u32 = 3;
const RETRY_DELAY_MS: u64 = 1000;
Expand Down
216 changes: 187 additions & 29 deletions etc/trustify-cli/src/api/sbom.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufReader, Write};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};

use futures::future::join_all;
use futures::stream::{self, StreamExt};
use std::{
collections::HashMap,
fs::File,
io::{BufReader, Write},
path::Path,
sync::Arc,
};

use chrono::{DateTime, Duration, Local};
use futures::{
future::join_all,
stream::{self, StreamExt},
};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use log;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::Mutex;
Expand Down Expand Up @@ -35,6 +40,25 @@ pub struct FindDuplicatesParams {
pub concurrency: usize,
}

/// Parameters for pruning SBOMs
#[derive(Default, Serialize)]
pub struct PruneParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub q: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_before: Option<DateTime<Local>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub older_than: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_latest: Option<u32>,
pub dry_run: bool,
pub concurrency: usize,
}

/// SBOM entry for duplicate detection
#[derive(Debug, Clone)]
struct SbomEntry {
Expand Down Expand Up @@ -357,24 +381,135 @@ pub async fn delete_by_query(
);
}
return Ok(DeleteResult {
deleted: 0,
skipped: 0,
failed: 0,
deleted: vec![],
deleted_total: 0,
skipped: vec![],
skipped_total: 0,
failed: vec![],
failed_total: 0,
total,
});
}

delete_list(client, entries, concurrency).await
}

/// Result of deleting duplicates
/// Prune SBOMs based on the given parameters
pub async fn prune(client: &ApiClient, params: &PruneParams) -> Result<DeleteResult, ApiError> {
// Build query parameters for listing SBOMs to prune
let mut query = params.q.as_deref().unwrap_or("").to_string();
if let Some(d) = params.published_before.as_ref() {
query.push_str(&format!("&published<{}", d.to_rfc3339()));
}

if let Some(older_than) = params.older_than {
let older_than_time = Local::now() - Duration::days(older_than);
query.push_str(&format!("&ingested<{}", older_than_time.to_rfc3339()));
}

if let Some(labels) = &params.label {
for l in labels.iter() {
query.push_str(&format!("&labels:{}", l));
}
}

let (offset, sort) = match params.keep_latest {
Some(v) => (Some(v), Some("ingested:desc".to_string())),
None => (None, None),
};
let list_params = ListParams {
q: Some(query),
limit: params.limit,
offset,
sort,
};

log::info!(
"Pruning SBOMs with query: {}, offset: {:?}, sort: {:?}",
list_params.q.as_deref().unwrap_or(""),
list_params.offset,
list_params.sort
);

// Get list of SBOMs matching the criteria
let response = list(client, &list_params).await?;
let parsed: Value = serde_json::from_str(&response)
.map_err(|e| ApiError::InternalError(format!("Failed to parse response: {}", e)))?;

let items = parsed
.get("items")
.and_then(|v| v.as_array())
.ok_or_else(|| ApiError::InternalError("No items in response".to_string()))?;

let total = items.len() as u32;

// Convert items to delete entries
let entries: Vec<DeleteEntry> = items
.iter()
.filter_map(|item| {
let id = item.get("id").and_then(|v| v.as_str())?;
let document_id = item
.get("document_id")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
Some(DeleteEntry {
id: id.to_string(),
document_id: document_id.to_string(),
})
})
.collect();

// If dry run, just return the count without deleting
if params.dry_run {
return Ok(DeleteResult {
deleted: vec![],
deleted_total: 0,
skipped: vec![],
skipped_total: 0,
failed: vec![],
failed_total: 0,
total,
});
}

// Perform the actual deletion
delete_list(client, entries, params.concurrency).await
}

#[derive(Debug, Clone, Serialize)]
/// Result of deleting SBOMs
pub struct DeleteResult {
pub deleted: u32,
pub skipped: u32,
pub failed: u32,
pub deleted: Vec<DeletedResult>,
pub deleted_total: u32,
pub skipped: Vec<SkippedResult>,
pub skipped_total: u32,
pub failed: Vec<FailedResult>,
pub failed_total: u32,
pub total: u32,
}

#[derive(Debug, Clone, Serialize)]
/// Successfully deleted SBOM
pub struct DeletedResult {
pub sbom_id: String,
pub document_id: String,
}

#[derive(Debug, Clone, Serialize)]
/// Skipped SBOM (not found)
pub struct SkippedResult {
pub sbom_id: String,
pub document_id: String,
}

#[derive(Debug, Clone, Serialize)]
/// Failed to delete SBOM
pub struct FailedResult {
pub sbom_id: String,
pub document_id: String,
pub error: String,
}

/// Entry to delete with its document_id for logging
#[derive(Clone)]
pub struct DeleteEntry {
Expand Down Expand Up @@ -421,7 +556,7 @@ pub async fn delete_list(
let total = entries.len() as u32;

eprintln!(
"Deleting {} duplicates with {} concurrent requests...\n",
"Deleting {} sboms with {} concurrent requests...\n",
total, concurrency
);

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

let deleted = Arc::new(AtomicU32::new(0));
let skipped = Arc::new(AtomicU32::new(0));
let failed = Arc::new(AtomicU32::new(0));
let deleted = Arc::new(Mutex::new(Vec::new()));
let skipped = Arc::new(Mutex::new(Vec::new()));
let failed = Arc::new(Mutex::new(Vec::new()));

stream::iter(entries)
.for_each_concurrent(concurrency, |entry| {
Expand All @@ -446,13 +581,26 @@ pub async fn delete_list(
async move {
match delete(&client, &entry.id).await {
Ok(_) => {
deleted.fetch_add(1, Ordering::Relaxed);
let mut deleted_list = deleted.lock().await;
deleted_list.push(DeletedResult {
sbom_id: entry.id.clone(),
document_id: entry.document_id.clone(),
});
}
Err(ApiError::NotFound(_)) => {
skipped.fetch_add(1, Ordering::Relaxed);
let mut skipped_list = skipped.lock().await;
skipped_list.push(SkippedResult {
sbom_id: entry.id.clone(),
document_id: entry.document_id.clone(),
});
}
Err(e) => {
failed.fetch_add(1, Ordering::Relaxed);
let mut failed_list = failed.lock().await;
failed_list.push(FailedResult {
sbom_id: entry.id.clone(),
document_id: entry.document_id.clone(),
error: e.to_string(),
});
progress.println(format!(
"Failed to delete {} (document_id: {}): {}",
entry.id, entry.document_id, e
Expand All @@ -466,10 +614,17 @@ pub async fn delete_list(

progress.finish_with_message("complete");

let deleted_list = deleted.lock().await;
let skipped_list = skipped.lock().await;
let failed_list = failed.lock().await;

Ok(DeleteResult {
deleted: deleted.load(Ordering::Relaxed),
skipped: skipped.load(Ordering::Relaxed),
failed: failed.load(Ordering::Relaxed),
deleted: deleted_list.clone(),
deleted_total: deleted_list.len() as u32,
skipped: skipped_list.clone(),
skipped_total: skipped_list.len() as u32,
failed: failed_list.clone(),
failed_total: failed_list.len() as u32,
total,
})
}
Expand All @@ -492,9 +647,12 @@ pub async fn delete_duplicates(
);
}
return Ok(DeleteResult {
deleted: 0,
skipped: 0,
failed: 0,
deleted: vec![],
deleted_total: 0,
skipped: vec![],
skipped_total: 0,
failed: vec![],
failed_total: 0,
total,
});
}
Expand Down
Loading
Loading