Skip to content

Commit b8ef6a0

Browse files
committed
chore: publish container image
Signed-off-by: Ruben Romero Montes <rromerom@redhat.com> Assisted-by: Cursor
1 parent b79c9ca commit b8ef6a0

6 files changed

Lines changed: 187 additions & 28 deletions

File tree

.dockerignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
target/
2+
.git/
3+
.gitignore
4+
*.md
5+
LICENSE
6+
Dockerfile
7+
.dockerignore
8+
.env
9+
duplicates.json
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: Build and Push Container Image
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
tags:
8+
- 'v*'
9+
pull_request:
10+
branches:
11+
- main
12+
13+
env:
14+
REGISTRY: ghcr.io
15+
IMAGE_NAME: ${{ github.repository }}
16+
17+
jobs:
18+
build-and-push:
19+
runs-on: ubuntu-latest
20+
permissions:
21+
contents: read
22+
packages: write
23+
24+
steps:
25+
- name: Checkout repository
26+
uses: actions/checkout@v4
27+
28+
- name: Set up Docker Buildx
29+
uses: docker/setup-buildx-action@v3
30+
31+
- name: Log in to Container Registry
32+
if: github.event_name != 'pull_request'
33+
uses: docker/login-action@v3
34+
with:
35+
registry: ${{ env.REGISTRY }}
36+
username: ${{ github.actor }}
37+
password: ${{ secrets.GITHUB_TOKEN }}
38+
39+
- name: Extract metadata (tags, labels)
40+
id: meta
41+
uses: docker/metadata-action@v5
42+
with:
43+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
44+
tags: |
45+
type=ref,event=branch
46+
type=ref,event=pr
47+
type=semver,pattern={{version}}
48+
type=semver,pattern={{major}}.{{minor}}
49+
type=semver,pattern={{major}}
50+
type=sha,prefix=
51+
type=raw,value=latest,enable={{is_default_branch}}
52+
53+
- name: Build and push Docker image
54+
uses: docker/build-push-action@v6
55+
with:
56+
context: .
57+
push: ${{ github.event_name != 'pull_request' }}
58+
tags: ${{ steps.meta.outputs.tags }}
59+
labels: ${{ steps.meta.outputs.labels }}
60+
cache-from: type=gha
61+
cache-to: type=gha,mode=max
62+
platforms: linux/amd64,linux/arm64

Dockerfile

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Build stage
2+
FROM rust:1.83-alpine AS builder
3+
4+
# Install musl-dev for static linking
5+
RUN apk add --no-cache musl-dev
6+
7+
WORKDIR /app
8+
9+
# Copy manifests first for better layer caching
10+
COPY Cargo.toml Cargo.lock ./
11+
12+
# Create a dummy main.rs to build dependencies
13+
RUN mkdir src && echo "fn main() {}" > src/main.rs
14+
15+
# Build dependencies only (this layer will be cached)
16+
RUN cargo build --release && rm -rf src
17+
18+
# Copy actual source code
19+
COPY src ./src
20+
21+
# Build the actual binary (touch to update mtime so cargo rebuilds)
22+
RUN touch src/main.rs && cargo build --release
23+
24+
# Runtime stage - use minimal distroless image
25+
FROM gcr.io/distroless/static-debian12:nonroot
26+
27+
# Copy the binary from builder
28+
COPY --from=builder /app/target/release/trustify /usr/local/bin/trustify
29+
30+
# Set the entrypoint
31+
ENTRYPOINT ["trustify"]

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,41 @@ cargo build --release
2525
# The binary will be at ./target/release/trustify
2626
```
2727

28+
### Using Docker
29+
30+
Pull and run the pre-built image:
31+
32+
```bash
33+
# Run with environment variables
34+
docker run --rm \
35+
-e TRUSTIFY_URL=https://trustify.example.com \
36+
-e TRUSTIFY_SSO_URL=https://sso.example.com/realms/trustify \
37+
-e TRUSTIFY_CLIENT_ID=my-client \
38+
-e TRUSTIFY_CLIENT_SECRET=my-secret \
39+
ghcr.io/ruromero/trustify-cli sbom list
40+
41+
# Or use an env file
42+
docker run --rm --env-file .env ghcr.io/ruromero/trustify-cli sbom list
43+
44+
# Mount a volume to save/load files (e.g., duplicates.json)
45+
docker run --rm --env-file .env \
46+
-v $(pwd):/data \
47+
ghcr.io/ruromero/trustify-cli sbom duplicates find --output /data/duplicates.json
48+
```
49+
50+
### Build Docker Image Locally
51+
52+
```bash
53+
# Clone and build
54+
git clone https://github.com/ruromero/trustify-cli.git
55+
cd trustify-cli
56+
57+
docker build -t trustify-cli .
58+
59+
# Run
60+
docker run --rm --env-file .env trustify-cli sbom list
61+
```
62+
2863
## Configuration
2964

3065
The CLI can be configured using command-line arguments, environment variables, or a `.env` file.

src/api/client.rs

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,31 +13,41 @@ const RETRY_DELAY_MS: u64 = 1000;
1313

1414
#[derive(Error, Debug, Clone)]
1515
pub enum ApiError {
16-
#[error("Request failed: {0}")]
17-
RequestError(String),
16+
#[error("Network error: {0}")]
17+
NetworkError(String),
1818

19-
#[error("Not found: {0}")]
19+
#[error("HTTP {0}: {1}")]
20+
HttpError(u16, String),
21+
22+
#[error("HTTP 404: Resource not found")]
2023
NotFound(String),
2124

22-
#[error("Unauthorized: Please check your authentication credentials")]
25+
#[error("HTTP 401: Please check your authentication credentials")]
2326
Unauthorized,
2427

25-
#[error("Token expired")]
28+
#[error("HTTP 401: Token expired")]
2629
TokenExpired,
2730

28-
#[error("Server timeout - please retry")]
29-
Timeout,
31+
#[error("HTTP {0}: Server timeout")]
32+
Timeout(u16),
33+
34+
#[error("HTTP {0}: {1}")]
35+
ServerError(u16, String),
3036

31-
#[error("Server error: {0}")]
32-
ServerError(String),
37+
#[error("{0}")]
38+
InternalError(String),
3339
}
3440

3541
impl From<reqwest::Error> for ApiError {
3642
fn from(e: reqwest::Error) -> Self {
3743
if e.is_timeout() {
38-
ApiError::Timeout
44+
ApiError::Timeout(0) // 0 indicates network-level timeout (no HTTP response)
45+
} else if e.is_connect() {
46+
ApiError::NetworkError(format!("Connection failed: {}", e))
47+
} else if e.is_request() {
48+
ApiError::NetworkError(format!("Request error: {}", e))
3949
} else {
40-
ApiError::RequestError(e.to_string())
50+
ApiError::NetworkError(e.to_string())
4151
}
4252
}
4353
}
@@ -158,7 +168,7 @@ impl ApiClient {
158168
F: Fn() -> Fut,
159169
Fut: std::future::Future<Output = Result<String, ApiError>>,
160170
{
161-
let mut last_error = ApiError::RequestError("No attempts made".to_string());
171+
let mut last_error = ApiError::NetworkError("No attempts made".to_string());
162172
let mut token_refreshed = false;
163173

164174
for attempt in 0..MAX_RETRIES {
@@ -174,18 +184,21 @@ impl ApiClient {
174184
}
175185
return Err(ApiError::Unauthorized);
176186
}
177-
Err(ApiError::Timeout) | Err(ApiError::ServerError(_))
187+
Err(ref e @ ApiError::Timeout(_))
188+
| Err(ref e @ ApiError::ServerError(_, _))
189+
| Err(ref e @ ApiError::NetworkError(_))
178190
if attempt < MAX_RETRIES - 1 =>
179191
{
180192
let delay = RETRY_DELAY_MS * (attempt as u64 + 1);
181193
eprintln!(
182-
"Request failed, retrying in {}ms... (attempt {}/{})",
194+
"{}, retrying in {}ms... (attempt {}/{})",
195+
e,
183196
delay,
184197
attempt + 1,
185198
MAX_RETRIES
186199
);
187200
sleep(Duration::from_millis(delay)).await;
188-
last_error = ApiError::Timeout;
201+
last_error = e.clone();
189202
}
190203
Err(e) => return Err(e),
191204
}
@@ -196,6 +209,7 @@ impl ApiClient {
196209

197210
async fn handle_response(&self, response: reqwest::Response) -> Result<String, ApiError> {
198211
let status = response.status();
212+
let status_code = status.as_u16();
199213

200214
if status.is_success() {
201215
Ok(response.text().await?)
@@ -206,10 +220,14 @@ impl ApiClient {
206220
} else if status == StatusCode::FORBIDDEN {
207221
Err(ApiError::Unauthorized)
208222
} else if status == StatusCode::GATEWAY_TIMEOUT || status == StatusCode::REQUEST_TIMEOUT {
209-
Err(ApiError::Timeout)
223+
Err(ApiError::Timeout(status_code))
210224
} else {
211225
let body = response.text().await.unwrap_or_default();
212-
Err(ApiError::ServerError(format!("HTTP {}: {}", status, body)))
226+
if status.is_server_error() {
227+
Err(ApiError::ServerError(status_code, body))
228+
} else {
229+
Err(ApiError::HttpError(status_code, body))
230+
}
213231
}
214232
}
215233
}

src/api/sbom.rs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,12 @@ async fn fetch_page(
7878

7979
let response = list(client, &list_params).await?;
8080
let parsed: Value = serde_json::from_str(&response)
81-
.map_err(|e| ApiError::ServerError(format!("Failed to parse response: {}", e)))?;
81+
.map_err(|e| ApiError::InternalError(format!("Failed to parse response: {}", e)))?;
8282

8383
let items = parsed
8484
.get("items")
8585
.and_then(|v| v.as_array())
86-
.ok_or_else(|| ApiError::ServerError("No items in response".to_string()))?;
86+
.ok_or_else(|| ApiError::InternalError("No items in response".to_string()))?;
8787

8888
let entries: Vec<SbomEntry> = items
8989
.iter()
@@ -165,7 +165,7 @@ pub async fn find_duplicates(
165165
.await?;
166166

167167
let parsed: Value = serde_json::from_str(&first_page)
168-
.map_err(|e| ApiError::ServerError(format!("Failed to parse response: {}", e)))?;
168+
.map_err(|e| ApiError::InternalError(format!("Failed to parse response: {}", e)))?;
169169

170170
let total = parsed.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
171171

@@ -238,7 +238,7 @@ pub async fn find_duplicates(
238238
join_all(handles).await;
239239

240240
let all_entries = Arc::try_unwrap(results)
241-
.map_err(|_| ApiError::ServerError("Failed to unwrap entries".to_string()))?
241+
.map_err(|_| ApiError::InternalError("Failed to unwrap entries".to_string()))?
242242
.into_inner();
243243

244244
eprintln!("\nProcessing {} SBOMs for duplicates...", all_entries.len());
@@ -291,13 +291,13 @@ pub async fn find_duplicates(
291291
.unwrap_or("duplicates.json");
292292

293293
let json = serde_json::to_string_pretty(&duplicate_groups)
294-
.map_err(|e| ApiError::ServerError(format!("Failed to serialize results: {}", e)))?;
294+
.map_err(|e| ApiError::InternalError(format!("Failed to serialize results: {}", e)))?;
295295

296296
let mut file = File::create(output_path)
297-
.map_err(|e| ApiError::ServerError(format!("Failed to create output file: {}", e)))?;
297+
.map_err(|e| ApiError::InternalError(format!("Failed to create output file: {}", e)))?;
298298

299299
file.write_all(json.as_bytes())
300-
.map_err(|e| ApiError::ServerError(format!("Failed to write to file: {}", e)))?;
300+
.map_err(|e| ApiError::InternalError(format!("Failed to write to file: {}", e)))?;
301301

302302
Ok(duplicate_groups)
303303
}
@@ -334,19 +334,19 @@ pub async fn delete_duplicates(
334334
// Check if file exists
335335
let path = Path::new(input_file);
336336
if !path.exists() {
337-
return Err(ApiError::ServerError(format!(
337+
return Err(ApiError::InternalError(format!(
338338
"Input file not found: {}",
339339
input_file
340340
)));
341341
}
342342

343343
// Read and parse the file
344344
let file = File::open(path)
345-
.map_err(|e| ApiError::ServerError(format!("Failed to open input file: {}", e)))?;
345+
.map_err(|e| ApiError::InternalError(format!("Failed to open input file: {}", e)))?;
346346
let reader = BufReader::new(file);
347347

348348
let groups: Vec<DuplicateGroup> = serde_json::from_reader(reader)
349-
.map_err(|e| ApiError::ServerError(format!("Failed to parse input file: {}", e)))?;
349+
.map_err(|e| ApiError::InternalError(format!("Failed to parse input file: {}", e)))?;
350350

351351
// Collect all duplicate entries to delete
352352
let entries: Vec<DeleteEntry> = groups
@@ -410,8 +410,12 @@ pub async fn delete_duplicates(
410410
// SBOM already deleted or doesn't exist - skip silently
411411
skipped.fetch_add(1, Ordering::Relaxed);
412412
}
413-
Err(_) => {
413+
Err(e) => {
414414
failed.fetch_add(1, Ordering::Relaxed);
415+
progress.println(format!(
416+
"Failed to delete {} (document_id: {}): {}",
417+
entry.id, entry.document_id, e
418+
));
415419
}
416420
}
417421
progress.inc(1);

0 commit comments

Comments
 (0)