From 8a27b136b71be5216c38a00e24c9698fd2c8dbf7 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 27 Apr 2026 03:02:53 +0000 Subject: [PATCH] Revert "ci: adapt CI to ubuntu-22.04 runner with proven linux_patch_api patterns" This reverts commit f8bac859032e2914e24fda2034d94e88850c6b98. --- .gitea/workflows/ci.yml | 208 +++++++++++++++++++++--------- ARCHITECTURE.md | 136 -------------------- crates/pm-reports/src/pdf.rs | 210 ------------------------------- frontend/src/pages/UsersPage.tsx | 96 +++++++++++--- tasks/lessons.md | 4 - 5 files changed, 229 insertions(+), 425 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3db1f2c..9974b9d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,6 +1,6 @@ name: CI Pipeline -"on": +on: push: branches: [master] tags: ["v*"] @@ -12,23 +12,42 @@ env: RUST_BACKTRACE: 1 jobs: + # ─── Quality Gates (run on every push/PR/tag) ─── + rust-format: name: Rust Format Check runs-on: ubuntu-22.04 steps: + - name: Install checkout dependencies + run: | + apt-get update -qq + apt-get install -y --no-install-recommends curl ca-certificates + - name: Checkout repository run: | - curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz - tar -xzf repo.tar.gz --strip-components=1 - rm -f repo.tar.gz - - name: Install Rust + TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" + REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" + curl -sf -H "Authorization: token ${TOKEN}" \ + "http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \ + -o repo.tar.gz + tar xzf repo.tar.gz --strip-components=1 + rm repo.tar.gz + + - name: Ensure Rust toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + if ! command -v cargo &>/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + . "$HOME/.cargo/env" + fi . "$HOME/.cargo/env" rustup component add rustfmt - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + echo "Rust: $(cargo --version)" + echo "Rustfmt: $(rustfmt --version)" + - name: Check formatting - run: cargo fmt --all -- --check + run: | + . "$HOME/.cargo/env" + cargo fmt --check --all 2>&1 clippy: name: Clippy Lints @@ -36,21 +55,33 @@ jobs: steps: - name: Install system dependencies run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends curl ca-certificates build-essential pkg-config libssl-dev + apt-get update -qq + apt-get install -y --no-install-recommends curl ca-certificates pkg-config libssl-dev + - name: Checkout repository run: | - curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz - tar -xzf repo.tar.gz --strip-components=1 - rm -f repo.tar.gz - - name: Install Rust + TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" + REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" + curl -sf -H "Authorization: token ${TOKEN}" \ + "http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \ + -o repo.tar.gz + tar xzf repo.tar.gz --strip-components=1 + rm repo.tar.gz + + - name: Ensure Rust toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + if ! command -v cargo &>/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + . "$HOME/.cargo/env" + fi . "$HOME/.cargo/env" rustup component add clippy - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + echo "Rust: $(cargo --version)" + - name: Run Clippy - run: cargo clippy --all-targets --all-features -- -D warnings + run: | + . "$HOME/.cargo/env" + cargo clippy --all-targets --all-features 2>&1 rust-test: name: Rust Unit Tests @@ -58,39 +89,70 @@ jobs: steps: - name: Install system dependencies run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends curl ca-certificates build-essential pkg-config libssl-dev + apt-get update -qq + apt-get install -y --no-install-recommends curl ca-certificates pkg-config libssl-dev + - name: Checkout repository run: | - curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz - tar -xzf repo.tar.gz --strip-components=1 - rm -f repo.tar.gz - - name: Install Rust + TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" + REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" + curl -sf -H "Authorization: token ${TOKEN}" \ + "http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \ + -o repo.tar.gz + tar xzf repo.tar.gz --strip-components=1 + rm repo.tar.gz + + - name: Ensure Rust toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + if ! command -v cargo &>/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + . "$HOME/.cargo/env" + fi . "$HOME/.cargo/env" - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + echo "Rust: $(cargo --version)" + - name: Run tests - run: cargo test --workspace --all-features + run: | + . "$HOME/.cargo/env" + cargo test --workspace --all-features 2>&1 security-audit: name: Security Audit runs-on: ubuntu-22.04 steps: + - name: Install checkout dependencies + run: | + apt-get update -qq + apt-get install -y --no-install-recommends curl ca-certificates + - name: Checkout repository run: | - curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz - tar -xzf repo.tar.gz --strip-components=1 - rm -f repo.tar.gz - - name: Install Rust + TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" + REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" + curl -sf -H "Authorization: token ${TOKEN}" \ + "http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \ + -o repo.tar.gz + tar xzf repo.tar.gz --strip-components=1 + rm repo.tar.gz + + - name: Ensure Rust toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + if ! command -v cargo &>/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + . "$HOME/.cargo/env" + fi . "$HOME/.cargo/env" - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - - name: Run cargo-audit + echo "Rust: $(cargo --version)" + + - name: Install cargo-audit run: | - cargo install cargo-audit - cargo audit --ignore RUSTSEC-2025-0134 + . "$HOME/.cargo/env" + cargo install cargo-audit 2>&1 + + - name: Run security audit + run: | + . "$HOME/.cargo/env" + cargo audit 2>&1 frontend-lint: name: Frontend Lint & Type Check @@ -98,22 +160,32 @@ jobs: steps: - name: Install checkout dependencies run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends curl ca-certificates nodejs npm + apt-get update -qq + apt-get install -y --no-install-recommends curl ca-certificates nodejs npm + - name: Checkout repository run: | - curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz - tar -xzf repo.tar.gz --strip-components=1 - rm -f repo.tar.gz + TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" + REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" + curl -sf -H "Authorization: token ${TOKEN}" \ + "http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \ + -o repo.tar.gz + tar xzf repo.tar.gz --strip-components=1 + rm repo.tar.gz + - name: Install Node.js dependencies working-directory: frontend run: npm ci + - name: Run ESLint working-directory: frontend - run: npx eslint src/ --ext .ts,.tsx --max-warnings 0 + run: npx eslint src/ --ext .ts,.tsx --max-warnings 0 2>&1 + - name: TypeScript type check working-directory: frontend - run: npx tsc --noEmit + run: npx tsc --noEmit 2>&1 + + # ─── Build & Release (only on tag pushes, gated by quality checks) ─── build-and-release: name: Build .deb & Release @@ -123,48 +195,68 @@ jobs: steps: - name: Install system dependencies run: | - sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends \ - curl ca-certificates build-essential pkg-config libssl-dev \ + apt-get update -qq + apt-get install -y --no-install-recommends \ + curl ca-certificates pkg-config libssl-dev \ git nodejs npm dpkg-dev python3 + - name: Checkout repository run: | - curl -sfL -H "Authorization: token ${{ secrets.GITEATOKEN }}" "https://gitea-lxc.moon-dragon.us/echo/linux_patch_manager/archive/${GITHUB_SHA}.tar.gz" -o repo.tar.gz - tar -xzf repo.tar.gz --strip-components=1 - rm -f repo.tar.gz - - name: Install Rust + TOKEN="${GITHUB_TOKEN:-$GITEA_TOKEN}" + REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" + curl -sf -H "Authorization: token ${TOKEN}" \ + "http://192.168.2.189:3000/api/v1/repos/${REPO}/archive/${GITHUB_SHA}.tar.gz" \ + -o repo.tar.gz + tar xzf repo.tar.gz --strip-components=1 + rm repo.tar.gz + + - name: Ensure Rust toolchain run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + if ! command -v cargo &>/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + . "$HOME/.cargo/env" + fi . "$HOME/.cargo/env" - echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + echo "Rust: $(cargo --version)" + - name: Build Rust backend (release) - run: cargo build --release + run: | + . "$HOME/.cargo/env" + cargo build --release 2>&1 + - name: Run Rust tests - run: cargo test --workspace --all-features + run: | + . "$HOME/.cargo/env" + cargo test --workspace --all-features 2>&1 + - name: Strip binaries run: | strip target/release/pm-web target/release/pm-worker + - name: Build frontend run: | cd frontend && npm ci && npm run build + - name: Determine version run: | VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/') echo "VERSION=${VERSION}" >> "$GITHUB_ENV" echo "Building version: ${VERSION}" + - name: Assemble .deb package run: | chmod +x scripts/build-package.sh scripts/build-package.sh + - name: Verify package run: | ls -la target/package/*.deb dpkg-deb -I target/package/linux-patch-manager_*.deb - - name: Create Gitea Release + + - name: Create Gitea Release (tags only) if: startsWith(github.ref, 'refs/tags/v') - env: - GITEA_TOKEN: ${{ secrets.GITEATOKEN }} run: | + . "$HOME/.cargo/env" VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*=.*"\(.*\)"/\1/') REPO="${GITHUB_REPOSITORY:-echo/linux_patch_manager}" REF_NAME="${GITHUB_REF_NAME:-v${VERSION}}" @@ -174,5 +266,5 @@ jobs: --tag "${REF_NAME}" \ --title "Release ${REF_NAME}" \ --asset "${DEB}" \ - --token "${GITEA_TOKEN}" \ - --gitea-url "https://gitea-lxc.moon-dragon.us" + --token "${GITHUB_TOKEN:-$GITEA_TOKEN}" \ + --gitea-url "http://192.168.2.189:3000" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 88fb191..cae1538 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -689,139 +689,3 @@ HTTP status codes follow standard REST semantics (`400`, `401`, `403`, `404`, `4 | C-21 | Added §14 Design Rationale, §15 Risks and Trade-offs, §16 Open Issues, §17 Future Considerations | IEEE 1016 §7 (Design Rationale) was missing; risks and open issues give reviewers a clear audit surface | | C-22 | Replaced the Email Notifier arrow that pointed back into the web server's mTLS client on the original diagram with a correct component placement in §4.2 | Original diagram implied email flowed through the mTLS client, which is not the design | | C-23 | Added C-X change IDs throughout this log | Enables traceability in future reviews | - ---- - -## 6. Data Flow - -### 6.1 Host Registration - -``` -1. Admin enters FQDN / IP -> Web validates and resolves FQDN to IP. -2. Web inserts row in `hosts` (status = pending). -3. Web NOTIFYs `host_registered` -> Worker performs initial mTLS health check. -4. Worker updates `hosts.health_status` and `host_health_data` -> visible in Dashboard. -``` - -### 6.2 Auto-Discovery (CIDR scan) - -``` -1. Admin triggers CIDR scan -> Web inserts a discovery job and NOTIFYs `discovery_enqueued`. -2. Worker scans the subnet for agents listening on port 12443 (bounded concurrency, TLS probe). -3. Discovered agents written to a transient `discovery_results` table. -4. Admin reviews and selects which to register; each selection follows the 6.1 flow. -``` - -### 6.3 Patch Deployment — Queued - -``` -1. Operator selects hosts + patches -> "Queue for next window". -2. Web creates `patch_jobs` row (status = queued) and `patch_job_hosts` rows. -3. Job Scheduler detects the next applicable maintenance window per host. -4. At window open, Worker calls the Agent API to start patch operations. -5. Worker polls agent job status (and/or consumes WebSocket events) and updates rows. -6. WebSocket Relay pushes updates to subscribed browsers in real time. -7. Failed hosts are auto-retried once if still within the window (see §8). -``` - -### 6.4 Patch Deployment — Immediate - -``` -1. Operator selects hosts + patches -> "Apply Now". -2. Web creates `patch_jobs` row (status = pending) and NOTIFYs `job_enqueued`. -3. Worker wakes immediately and triggers the agent calls. -4. Same monitoring and retry logic as the queued flow. -``` - -### 6.5 Rollback - -``` -1. Operator opens a completed or failed job and clicks "Rollback". -2. Web creates a `patch_jobs` row with kind = rollback, parent_job_id = . -3. Worker calls `POST /api/v1/jobs/{id}/rollback` on each affected agent. -4. Results are tracked like any other job; audit log records the rollback actor. -``` - -### 6.6 Health / Patch Polling - -``` -1. Worker polls each agent on schedule (5 min health, 30 min patches). -2. Results cached in `host_health_data` and `host_patch_data`. -3. Unhealthy agents are flagged with visual alerts in the Dashboard. -4. On-demand refresh: operator clicks refresh -> Web NOTIFYs `refresh_requested`; Worker queries immediately. -``` - ---- - -## 7. Security Architecture - -### 7.1 Authentication - -- **Local accounts:** Argon2id-hashed passwords; TOTP or WebAuthn for MFA (enforced). -- **Azure SSO:** OAuth2 / OIDC Authorization Code flow with PKCE; Azure's built-in MFA satisfies the MFA requirement. -- **Access tokens:** JWT, signed with a rotating HS256 or EdDSA key (implementation choice); 15-minute TTL. -- **Refresh tokens:** Opaque, 256-bit, stored hashed in `refresh_tokens`; **1-hour sliding inactivity timeout** (rotated on use; revocable). -- **Revocation:** Admins can force-revoke a user's refresh tokens; next access-token expiry terminates all sessions. - -### 7.2 Authorization (RBAC) - -- **Admin** — Full access to all resources and settings. -- **Operator** — Can add / remove hosts and manage schedules / patches only for devices in their assigned groups. -- **Group scoping** — Enforced by middleware at every API endpoint that touches host-scoped data. -- **Ungrouped hosts** — Accessible by any operator or admin (explicit product decision). - -### 7.3 Agent Communication - -- **mTLS** — Client certificate authentication for every agent call and WebSocket. -- **TLS 1.3 only** — Older TLS versions are refused at the Rustls configuration layer. -- **Internal CA** — Manager issues and renews client certificates. -- **Manual distribution** — Server administrators install certs on managed clients; the Manager holds no credentials for managed hosts and cannot push files to them. - -### 7.4 Data Protection - -- **Encryption at rest** — LUKS full-disk encryption, managed by the underlying infrastructure. This is the single mechanism of record; column-level encryption is **not** used (contrasts with an earlier `REQUIREMENTS.md` wording; see §14 Open Issues). -- **Encryption in transit** — TLS 1.3 for all agent and browser connections. -- **Audit log integrity** — Hash-chained rows (`audit_log.prev_hash`, `audit_log.row_hash`); integrity verified by a periodic check job and on-demand from the UI. -- **Password storage** — Argon2id with per-user salt and parameters calibrated for ~250 ms on the deployment hardware. -- **Secrets on disk** — Configuration secrets (JWT key, CA private key, DB password) are stored in `/etc/patch-manager/secrets/` with `0600` permissions, owned by the service user; not committed to the repository. - -### 7.5 Compliance Mapping - -- **HIPAA §164.312:** Audit controls (§7.4), access controls (§7.2 + MFA), integrity controls (hash-chained audit), transmission security (TLS 1.3 / mTLS), automatic logoff (1-hour inactivity). -- **PCI-DSS:** Requirement 6 (vulnerability management — the core function), Requirement 7 (need-to-know via group scoping), Requirement 8 (MFA, unique IDs), Requirement 10 (audit with 6-month retention), Requirements 3 & 4 (encryption at rest and in transit). - ---- - -## 8. Error Handling and Reliability - -### 8.1 Agent Communication Failures - -- Mark host as **unhealthy** in the Dashboard. -- Retry with **exponential backoff**: up to **3 retries**, capped at **30 minutes** between attempts (example schedule: 1 min, 5 min, 30 min). -- Continue processing other hosts without blocking. -- After exhausting retries, the host is flagged and reported in the next compliance report. - -### 8.2 Patch Job Failures - -- Auto-retry a failed patch job **once** if still within the maintenance window. -- If the retry fails, or the window has closed, surface the failure prominently in the Jobs view and in any configured email notifications. - -### 8.3 Batch Operations with Partial Failures - -- Auto-retry failed hosts **once**. -- If retry fails, report the failed hosts in the job detail view and let the operator decide next steps. -- Successful hosts complete normally regardless of failures elsewhere in the batch. - -### 8.4 API Error Response Format - -All Manager API errors use a consistent JSON envelope: - -```json -{ - "error": { - "code": "host_not_found", - "message": "No host with id 42 in any group you can access.", - "request_id": "01JF8Q…", - "details": {} - } -} diff --git a/crates/pm-reports/src/pdf.rs b/crates/pm-reports/src/pdf.rs index 0758cff..c8d9c7c 100644 --- a/crates/pm-reports/src/pdf.rs +++ b/crates/pm-reports/src/pdf.rs @@ -564,214 +564,4 @@ 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> { - 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 = rows.iter() - .map(|r| r.try_get::("display_name").unwrap_or_default()) - .collect(); - let values: Vec = rows.iter() - .map(|r| r.try_get::("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> { - 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 = std::collections::BTreeMap::new(); - for row in &rows { - if let Ok(Some(started)) = row.try_get::>, _>("started_at") { - *day_counts.entry(started.format("%Y-%m-%d").to_string()).or_insert(0.0) += 1.0; - } - } - let chart_labels: Vec = day_counts.keys().cloned().collect(); - let chart_values: Vec = 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::>, _>("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::>, _>("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> { - 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::>, _>("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() } diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx index cd25c8a..7398630 100644 --- a/frontend/src/pages/UsersPage.tsx +++ b/frontend/src/pages/UsersPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react' import { Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions, - DialogContent, DialogTitle, IconButton, MenuItem, Paper, Select, Table, - TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography, + DialogContent, DialogTitle, IconButton, MenuItem, Paper, Select, + Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + TextField, Toolbar, Tooltip, Typography, } from '@mui/material' import { Add as AddIcon, Lock as LockIcon } from '@mui/icons-material' import { apiClient } from '../api/client' @@ -16,16 +17,26 @@ export default function UsersPage() { const load = async () => { setLoading(true) - try { const r = await apiClient.get('/users'); setUsers(r.data) } + try { + const r = await apiClient.get('/users') + setUsers(r.data) + } catch { /* interceptor handles */ } finally { setLoading(false) } } useEffect(() => { load() }, []) const handleCreate = async () => { - await apiClient.post('/users', form) - setOpen(false); setForm({ username: '', email: '', role: 'operator', password: '' }) - load() + try { + await apiClient.post('/users', form) + setOpen(false) + setForm({ username: '', email: '', role: 'operator', password: '' }) + load() + } catch { /* interceptor handles */ } + } + + const handleRevoke = async (id: string) => { + await apiClient.post(`/users/${id}/revoke`) } return ( @@ -34,24 +45,45 @@ export default function UsersPage() { Users - {loading ? : ( + + {loading ? ( + + ) : ( - - UsernameEmail - RoleMFA - StatusActions - + + + Username + Email + Role + MFA + Status + Actions + + {users.map(u => ( {u.username} {u.email} - - - - apiClient.post(`/users/${u.id}/revoke`)}> + + + + + + + + + + + handleRevoke(u.id)}> + + + ))} @@ -59,7 +91,37 @@ export default function UsersPage() {
)} + setOpen(false)} maxWidth="xs" fullWidth> Add User - setForm({...form, username: e.target.value})} + setForm({ ...form, username: e.target.value })} + margin="normal" required /> + setForm({ ...form, email: e.target.value })} + margin="normal" required /> + setForm({ ...form, password: e.target.value })} + margin="normal" required /> + + + + + + + + + ) +} diff --git a/tasks/lessons.md b/tasks/lessons.md index 9156fee..b938462 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -65,7 +65,3 @@ The Docker container intercepted some jobs and ran them in its Alpine environmen **Fix:** Stopped Docker container runner. Switched to runs-on: ubuntu-latest with docker://ubuntu:24.04 containers. **Lesson:** Check for multiple runners with same name. Stop after 2 attempts and diagnose root cause. - -## CI/CD Runner Dual-Registration Root Cause (2026-04-24) - -**Problem:** CI jobs kept failing with