Private
Public Access
1
0

ci: adapt CI to ubuntu-22.04 runner with proven linux_patch_api patterns
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 0s
CI Pipeline / Clippy Lints (push) Failing after 11s
CI Pipeline / Rust Unit Tests (push) Failing after 1s
CI Pipeline / Security Audit (push) Failing after 0s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 2s
CI Pipeline / Build .deb & Release (push) Has been skipped

- Pin all jobs to ubuntu-22.04 runner
- Use curl -sfL with secrets.GITEATOKEN for checkout
- Switch checkout URL to https://gitea-lxc.moon-dragon.us
- Install rustup with --default-toolchain stable --profile minimal
- Add cargo bin to GITHUB_PATH instead of sourcing per-step
- Enforce clippy -D warnings
- Ignore RUSTSEC-2025-0134 in cargo audit
- Pass GITEA_TOKEN via env for release step
This commit is contained in:
2026-04-27 02:43:46 +00:00
parent bcb93c1d2d
commit f8bac85903
5 changed files with 425 additions and 229 deletions

View File

@ -564,4 +564,214 @@ ORDER BY created_at DESC LIMIT 10000",
);
}
pdf.save()
}
if let Some(gid) = params.group_id {
pdf.write_text(
&format!("Group: {}", gid),
10.0, MARGIN, 128.0, false,
);
}
pdf.new_page();
}
// ---------------------------------------------------------------------------
// Compliance PDF
// ---------------------------------------------------------------------------
async fn compliance_pdf(
pool: &sqlx::PgPool,
params: &ReportParams,
) -> anyhow::Result<Vec<u8>> {
use sqlx::Row;
let rows = if let Some(gid) = params.group_id {
sqlx::query("
SELECT h.display_name, h.fqdn,
COALESCE(pd.total_packages, 0) AS total_packages,
COALESCE(pd.pending_patches, 0) AS pending_patches,
CASE WHEN COALESCE(pd.total_packages,0) = 0 THEN 100.0
ELSE ROUND((1.0 - pd.pending_patches::float / NULLIF(pd.total_packages,0)) * 100, 1)
END AS compliance_pct,
h.health_status::text AS health_status
FROM hosts h
LEFT JOIN host_patch_data pd ON pd.host_id = h.id
WHERE h.id IN (SELECT host_id FROM host_groups WHERE group_id = $1)
GROUP BY h.id, pd.total_packages, pd.pending_patches
ORDER BY compliance_pct ASC")
.bind(gid).fetch_all(pool).await
.context("compliance PDF query (group) failed")?
} else {
sqlx::query("
SELECT h.display_name, h.fqdn,
COALESCE(pd.total_packages, 0) AS total_packages,
COALESCE(pd.pending_patches, 0) AS pending_patches,
CASE WHEN COALESCE(pd.total_packages,0) = 0 THEN 100.0
ELSE ROUND((1.0 - pd.pending_patches::float / NULLIF(pd.total_packages,0)) * 100, 1)
END AS compliance_pct,
h.health_status::text AS health_status
FROM hosts h
LEFT JOIN host_patch_data pd ON pd.host_id = h.id
GROUP BY h.id, pd.total_packages, pd.pending_patches
ORDER BY compliance_pct ASC")
.fetch_all(pool).await
.context("compliance PDF query failed")?
};
let labels: Vec<String> = rows.iter()
.map(|r| r.try_get::<String, _>("display_name").unwrap_or_default())
.collect();
let values: Vec<f64> = rows.iter()
.map(|r| r.try_get::<f64, _>("compliance_pct").unwrap_or(0.0))
.collect();
let mut pdf = PdfBuilder::new("Compliance Report")?;
write_title_page(&mut pdf, "Compliance Report", params);
let col_x: &[f32] = &[MARGIN, 65.0, 130.0, 165.0, 195.0, 230.0];
pdf.table_row(&["Host", "FQDN", "Total Pkgs", "Pending", "Compliance %", "Status"], col_x, 9.0, true);
for row in &rows {
let name: String = row.try_get("display_name").unwrap_or_default();
let fqdn: String = row.try_get("fqdn").unwrap_or_default();
let total: i64 = row.try_get("total_packages").unwrap_or(0);
let pending: i64 = row.try_get("pending_patches").unwrap_or(0);
let pct: f64 = row.try_get("compliance_pct").unwrap_or(0.0);
let status: String = row.try_get("health_status").unwrap_or_default();
pdf.table_row(
&[&name, &fqdn, &total.to_string(), &pending.to_string(), &format!("{:.1}%", pct), &status],
col_x, 8.0, false,
);
}
if !labels.is_empty() {
match render_bar_chart(&labels, &values, "Compliance % by Host") {
Ok(png) => {
pdf.new_page();
pdf.write_text("Compliance Chart", 16.0, MARGIN, 200.0, true);
if let Err(e) = pdf.embed_image(&png, MARGIN, 10.0, 0.18, 0.18) {
tracing::warn!(error = %e, "compliance chart embed failed");
}
}
Err(e) => tracing::warn!(error = %e, "compliance chart render failed"),
}
}
pdf.save()
}
// ---------------------------------------------------------------------------
// Patch history PDF
// ---------------------------------------------------------------------------
async fn patch_history_pdf(
pool: &sqlx::PgPool,
params: &ReportParams,
) -> anyhow::Result<Vec<u8>> {
use sqlx::Row;
let rows = sqlx::query("
SELECT pj.kind::text AS job_kind, pj.status::text AS job_status,
h.display_name, h.fqdn, pjh.started_at, pjh.completed_at,
EXTRACT(EPOCH FROM (pjh.completed_at - pjh.started_at))::bigint AS duration_seconds,
COALESCE(u.username, 'system') AS operator
FROM patch_job_hosts pjh
JOIN patch_jobs pj ON pj.id = pjh.job_id
JOIN hosts h ON h.id = pjh.host_id
LEFT JOIN users u ON u.id = pj.created_by_user_id
WHERE ($1::timestamptz IS NULL OR pjh.started_at >= $1)
AND ($2::timestamptz IS NULL OR pjh.started_at <= $2)
ORDER BY pjh.started_at DESC")
.bind(params.from).bind(params.to)
.fetch_all(pool).await
.context("patch history PDF query failed")?;
let mut day_counts: std::collections::BTreeMap<String, f64> = std::collections::BTreeMap::new();
for row in &rows {
if let Ok(Some(started)) = row.try_get::<Option<chrono::DateTime<chrono::Utc>>, _>("started_at") {
*day_counts.entry(started.format("%Y-%m-%d").to_string()).or_insert(0.0) += 1.0;
}
}
let chart_labels: Vec<String> = day_counts.keys().cloned().collect();
let chart_values: Vec<f64> = day_counts.values().cloned().collect();
let mut pdf = PdfBuilder::new("Patch History Report")?;
write_title_page(&mut pdf, "Patch History Report", params);
let col_x: &[f32] = &[MARGIN, 45.0, 80.0, 115.0, 155.0, 200.0, 245.0, 270.0];
pdf.table_row(&["Kind","Status","Host","FQDN","Started","Completed","Dur(s)","Operator"], col_x, 9.0, true);
for row in &rows {
let kind: String = row.try_get("job_kind").unwrap_or_default();
let status: String = row.try_get("job_status").unwrap_or_default();
let name: String = row.try_get("display_name").unwrap_or_default();
let fqdn: String = row.try_get("fqdn").unwrap_or_default();
let started: String = row.try_get::<Option<chrono::DateTime<chrono::Utc>>, _>("started_at")
.unwrap_or(None).map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default();
let completed: String = row.try_get::<Option<chrono::DateTime<chrono::Utc>>, _>("completed_at")
.unwrap_or(None).map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default();
let dur: i64 = row.try_get("duration_seconds").unwrap_or(0);
let op: String = row.try_get("operator").unwrap_or_default();
pdf.table_row(&[&kind,&status,&name,&fqdn,&started,&completed,&dur.to_string(),&op], col_x, 8.0, false);
}
if !chart_labels.is_empty() {
match render_bar_chart(&chart_labels, &chart_values, "Jobs per Day") {
Ok(png) => {
pdf.new_page();
pdf.write_text("Patch Activity Chart", 16.0, MARGIN, 200.0, true);
if let Err(e) = pdf.embed_image(&png, MARGIN, 10.0, 0.18, 0.18) {
tracing::warn!(error = %e, "patch history chart embed failed");
}
}
Err(e) => tracing::warn!(error = %e, "patch history chart render failed"),
}
}
pdf.save()
}
// ---------------------------------------------------------------------------
// Vulnerability PDF
// ---------------------------------------------------------------------------
async fn vulnerability_pdf(
pool: &sqlx::PgPool,
params: &ReportParams,
) -> anyhow::Result<Vec<u8>> {
use sqlx::Row;
let mut pdf = PdfBuilder::new("Vulnerability Report")?;
write_title_page(&mut pdf, "Vulnerability Exposure Report", params);
let col_x: &[f32] = &[MARGIN, 55.0, 100.0, 130.0, 175.0, 215.0, 255.0];
pdf.table_row(&["Host","FQDN","CVE ID","Package","Severity","Fix Version","Last Seen"], col_x, 9.0, true);
match sqlx::query("
SELECT h.display_name, h.fqdn,
cve.cve_id, cve.package_name, cve.severity, cve.available_version,
pd.updated_at AS last_seen_at
FROM hosts h
JOIN host_patch_data pd ON pd.host_id = h.id
CROSS JOIN LATERAL jsonb_to_recordset(COALESCE(pd.cve_data, '[]'::jsonb))
AS cve(cve_id text, package_name text, severity text, available_version text)
WHERE ($1::timestamptz IS NULL OR pd.updated_at >= $1)
AND ($2::timestamptz IS NULL OR pd.updated_at <= $2)
ORDER BY CASE cve.severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, h.display_name")
.bind(params.from).bind(params.to)
.fetch_all(pool).await
{
Ok(rows) => {
for row in &rows {
let name: String = row.try_get("display_name").unwrap_or_default();
let fqdn: String = row.try_get("fqdn").unwrap_or_default();
let cve_id: String = row.try_get("cve_id").unwrap_or_default();
let pkg: String = row.try_get("package_name").unwrap_or_default();
let sev: String = row.try_get("severity").unwrap_or_default();
let fix: String = row.try_get("available_version").unwrap_or_default();
let seen: String = row.try_get::<Option<chrono::DateTime<chrono::Utc>>, _>("last_seen_at")
.unwrap_or(None).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default();
pdf.table_row(&[&name,&fqdn,&cve_id,&pkg,&sev,&fix,&seen], col_x, 8.0, false);
}
}
Err(e) => {
tracing::warn!(error = %e, "vulnerability PDF query failed");
let y = pdf.current_y;
pdf.write_text(&format!("No data available: {}", e), 10.0, MARGIN, y, false);
}
}
pdf.save()
}