Private
Public Access
1
0

M5: Patch Deployment & Job Management

Backend:
- migrations/003_jobs_scheduling.sql: retry_next_at/last_error columns,
  pg_notify trigger for immediate job dispatch, retry index
- pm-agent-client: ApplyPatchesRequest/Response, AgentJobStatus,
  RollbackResponse types; apply_patches/job_status/rollback_job
  client methods + generic POST helper
- pm-core/models: JobStatus, JobKind, PatchJob, PatchJobHost,
  CreateJobRequest, PatchJobSummary
- pm-web/routes/jobs.rs: POST/GET /api/v1/jobs, GET /jobs/:id,
  POST /jobs/:id/cancel, POST /jobs/:id/rollback
- pm-worker/job_executor.rs: NOTIFY listener, periodic scanner,
  execute_host_job, poll_running_jobs, handle_host_failure (3-retry
  exponential backoff 1m/5m/30m), sync_job_status, retry_pending_jobs
- pm-worker/main.rs: spawn job_executor

Frontend:
- types/index.ts: PatchInfo, PatchJobHost, PatchJob, PatchJobSummary,
  CreateJobRequest interfaces
- api/client.ts: jobsApi (list/get/create/cancel/rollback),
  patchesApi (getHostPatches)
- pages/PatchDeploymentPage.tsx: 3-step MUI Stepper
  (host select → configure → result)
- pages/JobsPage.tsx: job list table, expandable per-host detail,
  cancel/rollback actions with confirm dialog, load-more pagination
- App.tsx: /jobs and /deployment routes wired to real pages

cargo check: 0 errors | vite build: 0 errors
This commit is contained in:
2026-04-23 17:08:43 +00:00
parent a6eb762962
commit 6f9c6dc881
30 changed files with 8465 additions and 44 deletions

View File

@ -1,10 +1,284 @@
//! Agent HTTP client stub.
//! Full mTLS Rustls-based implementation arrives in M4.
//! mTLS HTTP client for communicating with Linux Patch API agents.
//!
//! # Example
//!
//! ```no_run
//! use pm_agent_client::client::AgentClient;
//!
//! # async fn example() -> Result<(), pm_agent_client::error::AgentClientError> {
//! let client = AgentClient::new(
//! "192.168.1.10",
//! 12443,
//! include_bytes!("../certs/client.crt"),
//! include_bytes!("../certs/client.key"),
//! include_bytes!("../certs/ca.crt"),
//! )?;
//!
//! let health = client.health().await?;
//! println!("Agent status: {}", health.status);
//! # Ok(())
//! # }
//! ```
use thiserror::Error;
use std::time::Duration;
#[derive(Debug, Error)]
pub enum AgentClientError {
#[error("Not yet implemented")]
NotImplemented,
use reqwest::{
tls::Version,
Certificate, ClientBuilder, Identity,
};
use serde::{de::DeserializeOwned, Serialize};
use tracing::{debug, instrument};
use crate::{
error::AgentClientError,
types::{
AgentEnvelope, HealthData, PackagesData, PatchesData, SystemInfoData,
ApplyPatchesRequest, ApplyPatchesResponse, AgentJobStatus, RollbackResponse,
},
};
/// Default TCP port that the Linux Patch API agent listens on.
pub const DEFAULT_AGENT_PORT: u16 = 12443;
/// Request timeout applied to every agent API call.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
// ============================================================
// AgentClient
// ============================================================
/// Async HTTP client that speaks mTLS to a single Linux Patch API agent.
///
/// Construct once via [`AgentClient::new`] and reuse across calls;
/// the underlying [`reqwest::Client`] maintains a connection pool.
#[derive(Debug, Clone)]
pub struct AgentClient {
/// Underlying HTTP client (configured for mTLS + TLS 1.3).
inner: reqwest::Client,
/// Base URL of the agent, e.g. `https://10.0.0.5:12443/api/v1`.
base_url: String,
}
impl AgentClient {
/// Create a new [`AgentClient`] configured for mTLS.
///
/// # Arguments
///
/// * `host_ip` IP address (or hostname) of the agent.
/// * `port` TCP port the agent listens on (default [`DEFAULT_AGENT_PORT`]).
/// * `client_cert_pem` PEM-encoded client certificate presented during the TLS handshake.
/// * `client_key_pem` PEM-encoded private key matching `client_cert_pem`.
/// * `ca_cert_pem` PEM-encoded CA certificate used to verify the agent's server cert.
///
/// # Errors
///
/// Returns [`AgentClientError::Tls`] when certificate parsing fails, or
/// [`AgentClientError::Request`] when `reqwest` client construction fails.
pub fn new(
host_ip: &str,
port: u16,
client_cert_pem: &[u8],
client_key_pem: &[u8],
ca_cert_pem: &[u8],
) -> Result<Self, AgentClientError> {
// Build client identity: reqwest expects cert + key concatenated as PEM.
let mut identity_pem = Vec::with_capacity(client_cert_pem.len() + client_key_pem.len());
identity_pem.extend_from_slice(client_cert_pem);
identity_pem.extend_from_slice(client_key_pem);
let identity = Identity::from_pem(&identity_pem)
.map_err(|e| AgentClientError::Tls(format!("invalid client identity PEM: {e}")))?;
// Parse the CA certificate used to verify the agent's server certificate.
let ca_cert = Certificate::from_pem(ca_cert_pem)
.map_err(|e| AgentClientError::Tls(format!("invalid CA certificate PEM: {e}")))?;
// Build the reqwest client:
// - force rustls TLS backend
// - disable built-in OS/system trust roots (only trust our internal CA)
// - enforce TLS 1.3 minimum
// - attach client identity (mTLS)
// - add our CA as a trusted root
// - apply a global request timeout
let inner = ClientBuilder::new()
.use_rustls_tls()
.tls_built_in_root_certs(false)
.min_tls_version(Version::TLS_1_3)
.identity(identity)
.add_root_certificate(ca_cert)
.timeout(REQUEST_TIMEOUT)
.build()
.map_err(|e| AgentClientError::Request(e))?;
let base_url = format!("https://{}:{}/api/v1", host_ip, port);
tracing::debug!(base_url = %base_url, "AgentClient created");
Ok(Self { inner, base_url })
}
// --------------------------------------------------------
// Public API methods
// --------------------------------------------------------
/// `GET /api/v1/health` — check agent liveness and retrieve uptime.
#[instrument(skip(self), fields(base_url = %self.base_url))]
pub async fn health(&self) -> Result<HealthData, AgentClientError> {
self.get("health", &[]).await
}
/// `GET /api/v1/system/info` — retrieve host system information.
#[instrument(skip(self), fields(base_url = %self.base_url))]
pub async fn system_info(&self) -> Result<SystemInfoData, AgentClientError> {
self.get("system/info", &[]).await
}
/// `GET /api/v1/packages?status=upgradable` — list packages with available upgrades.
#[instrument(skip(self), fields(base_url = %self.base_url))]
pub async fn packages_upgradable(&self) -> Result<PackagesData, AgentClientError> {
self.get("packages", &[("status", "upgradable")]).await
}
/// `GET /api/v1/patches` — list available patches with severity and CVE data.
#[instrument(skip(self), fields(base_url = %self.base_url))]
pub async fn patches(&self) -> Result<PatchesData, AgentClientError> {
self.get("patches", &[]).await
}
// --------------------------------------------------------
// Private helpers
// --------------------------------------------------------
/// Execute a GET request against `{base_url}/{path}` with optional query
/// parameters, deserialize the [`AgentEnvelope`], and extract the `data`
/// field — or propagate an [`AgentClientError::ApiError`].
async fn get<T>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, AgentClientError>
where
T: DeserializeOwned,
{
let url = format!("{}/{}", self.base_url, path);
debug!(url = %url, ?query, "Sending GET request to agent");
let mut request = self.inner.get(&url);
if !query.is_empty() {
request = request.query(query);
}
let response = request.send().await?;
let status = response.status();
debug!(url = %url, status = %status, "Received response from agent");
// Capture body text so we can attempt to deserialise the error envelope
// even for non-2xx responses.
let body = response.text().await?;
// Attempt to parse the standard agent envelope regardless of HTTP status.
// The agent may embed a structured error body on 4xx/5xx responses.
let envelope: AgentEnvelope<T> = serde_json::from_str(&body)?;
if !status.is_success() || !envelope.success {
// Prefer the structured error from the envelope when present.
if let Some(err) = envelope.error {
return Err(AgentClientError::ApiError {
code: err.code,
message: err.message,
});
}
// Fallback: use the HTTP status as the error indicator.
return Err(AgentClientError::ApiError {
code: status.as_str().to_string(),
message: format!(
"Agent returned HTTP {} for {}",
status.as_u16(),
url
),
});
}
// On success the `data` field must be present.
envelope.data.ok_or_else(|| AgentClientError::ApiError {
code: "MISSING_DATA".to_string(),
message: "Agent response success=true but data field is absent".to_string(),
})
}
// --------------------------------------------------------
// Patch apply / job management methods
// --------------------------------------------------------
/// `POST /api/v1/patches/apply` — trigger patch application on the agent.
#[instrument(skip(self, req), fields(base_url = %self.base_url))]
pub async fn apply_patches(
&self,
req: &ApplyPatchesRequest,
) -> Result<ApplyPatchesResponse, AgentClientError> {
self.post("patches/apply", req).await
}
/// `GET /api/v1/jobs/{id}` — poll an async agent job for status.
#[instrument(skip(self), fields(base_url = %self.base_url, job_id = %job_id))]
pub async fn job_status(
&self,
job_id: &str,
) -> Result<AgentJobStatus, AgentClientError> {
self.get(&format!("jobs/{}", job_id), &[]).await
}
/// `POST /api/v1/jobs/{id}/rollback` — trigger rollback on the agent.
#[instrument(skip(self), fields(base_url = %self.base_url, job_id = %job_id))]
pub async fn rollback_job(
&self,
job_id: &str,
) -> Result<RollbackResponse, AgentClientError> {
let empty: serde_json::Value = serde_json::json!({});
self.post(&format!("jobs/{}/rollback", job_id), &empty).await
}
// --------------------------------------------------------
// Private POST helper
// --------------------------------------------------------
/// Execute a POST request against `{base_url}/{path}`, serialize `body` as
/// JSON, deserialize the [`AgentEnvelope`], and extract the `data` field —
/// or propagate an [`AgentClientError::ApiError`].
async fn post<Req, Resp>(
&self,
path: &str,
body: &Req,
) -> Result<Resp, AgentClientError>
where
Req: Serialize,
Resp: DeserializeOwned,
{
let url = format!("{}/{}", self.base_url, path);
debug!(url = %url, "Sending POST request to agent");
let response = self.inner.post(&url).json(body).send().await?;
let status = response.status();
debug!(url = %url, status = %status, "Received POST response from agent");
let body_text = response.text().await?;
let envelope: AgentEnvelope<Resp> = serde_json::from_str(&body_text)?;
if !status.is_success() || !envelope.success {
if let Some(err) = envelope.error {
return Err(AgentClientError::ApiError {
code: err.code,
message: err.message,
});
}
return Err(AgentClientError::ApiError {
code: status.as_str().to_string(),
message: format!("Agent returned HTTP {} for {}", status.as_u16(), url),
});
}
envelope.data.ok_or_else(|| AgentClientError::ApiError {
code: "MISSING_DATA".to_string(),
message: "Agent response success=true but data field is absent".to_string(),
})
}
}

View File

@ -0,0 +1,49 @@
//! Error types for the pm-agent-client crate.
use thiserror::Error;
/// Top-level error type returned by [`crate::client::AgentClient`] methods.
#[derive(Debug, Error)]
pub enum AgentClientError {
/// TLS configuration or handshake failure.
#[error("TLS error: {0}")]
Tls(String),
/// Unable to establish a TCP/TLS connection to the agent.
#[error("Connection error: {0}")]
Connect(#[source] reqwest::Error),
/// An HTTP request or response transport error (not a timeout).
#[error("Request error: {0}")]
Request(#[source] reqwest::Error),
/// The request did not complete within the configured timeout.
#[error("Request timed out")]
Timeout,
/// The agent returned a non-2xx HTTP status or `success: false` in the
/// response envelope.
#[error("Agent API error [{code}]: {message}")]
ApiError {
/// Machine-readable error code supplied by the agent (e.g. `"NOT_FOUND"`).
code: String,
/// Human-readable description returned by the agent.
message: String,
},
/// JSON deserialization of the agent response failed.
#[error("Failed to deserialise agent response: {0}")]
Deserialize(#[from] serde_json::Error),
}
impl From<reqwest::Error> for AgentClientError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
AgentClientError::Timeout
} else if err.is_connect() {
AgentClientError::Connect(err)
} else {
AgentClientError::Request(err)
}
}
}

View File

@ -1,4 +1,46 @@
//! pm-agent-client — mTLS HTTP client for Linux Patch API agent communication.
//! `pm-agent-client` — mTLS HTTP client for Linux Patch API agent communication.
//!
//! M1: Stub. Full implementation in M4.
//! This crate provides [`client::AgentClient`], an async HTTP client that
//! establishes mutual-TLS connections (TLS 1.3) to `linux_patch_api` agents
//! running on managed hosts.
//!
//! # Quick start
//!
//! ```no_run
//! use pm_agent_client::AgentClient;
//!
//! # async fn run() -> Result<(), pm_agent_client::AgentClientError> {
//! let client = AgentClient::new(
//! "10.0.1.5",
//! 12443,
//! include_bytes!("../certs/client.crt"),
//! include_bytes!("../certs/client.key"),
//! include_bytes!("../certs/ca.crt"),
//! )?;
//!
//! let health = client.health().await?;
//! println!("Agent {}: {}", health.status, health.version);
//! # Ok(())
//! # }
//! ```
pub mod client;
pub mod error;
pub mod types;
// ── Convenience re-exports ──────────────────────────────────────────────────
/// Primary client — re-exported from [`client::AgentClient`].
pub use client::{AgentClient, DEFAULT_AGENT_PORT};
/// Error type — re-exported from [`error::AgentClientError`].
pub use error::AgentClientError;
/// Response envelope and all data types.
pub use types::{
AgentEnvelope, AgentErrorBody,
HealthData,
Package, PackagesData,
Patch, PatchesData,
SystemInfoData,
};

View File

@ -0,0 +1,205 @@
//! Response and request types for the Linux Patch API agent endpoints.
//!
//! All agent responses are wrapped in [`AgentEnvelope<T>`].
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
// ============================================================
// Envelope & error
// ============================================================
/// Generic response wrapper returned by every agent endpoint.
///
/// ```json
/// { "success": true, "request_id": "…", "timestamp": "…", "data": {…}, "error": null }
/// ```
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentEnvelope<T> {
/// `true` when the request succeeded; `false` on error.
pub success: bool,
/// Server-assigned request identifier (UUID v4).
pub request_id: Uuid,
/// Server timestamp for the response (ISO-8601 / RFC-3339).
pub timestamp: DateTime<Utc>,
/// Response payload — present when `success` is `true`.
pub data: Option<T>,
/// Error detail — present when `success` is `false`.
pub error: Option<AgentErrorBody>,
}
/// Structured error returned inside [`AgentEnvelope::error`].
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentErrorBody {
/// Machine-readable error code (e.g. `"INTERNAL_ERROR"`).
pub code: String,
/// Human-readable description of what went wrong.
pub message: String,
/// Optional free-form extra detail from the agent.
#[serde(default)]
pub details: Option<serde_json::Value>,
/// Whether the caller may safely retry the request.
#[serde(default)]
pub retryable: bool,
}
// ============================================================
// GET /api/v1/health
// ============================================================
/// Payload returned by `GET /api/v1/health`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HealthData {
/// Agent status string, e.g. `"ok"` or `"degraded"`.
pub status: String,
/// Seconds elapsed since the agent process started.
pub uptime_seconds: u64,
/// Agent software version string.
pub version: String,
}
// ============================================================
// GET /api/v1/system/info
// ============================================================
/// Payload returned by `GET /api/v1/system/info`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SystemInfoData {
/// Hostname of the managed system.
pub hostname: String,
/// OS family / distribution name (e.g. `"Ubuntu"`).
pub os: String,
/// OS version string.
pub os_version: String,
/// Kernel version string.
pub kernel: String,
/// CPU architecture (e.g. `"x86_64"`).
pub architecture: String,
/// When the agent last checked for updates (`null` if never).
pub last_update_check: Option<DateTime<Utc>>,
/// When updates were last applied (`null` if never).
pub last_update_apply: Option<DateTime<Utc>>,
/// Whether the system has a pending reboot.
pub pending_reboot: bool,
}
// ============================================================
// GET /api/v1/packages?status=upgradable
// ============================================================
/// A single package entry.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Package {
/// Package name.
pub name: String,
/// Installed version.
pub version: String,
/// Package status string (e.g. `"installed"`, `"upgradable"`).
pub status: String,
/// Whether a newer version is available.
pub upgradable: bool,
/// Latest available version (`null` if not upgradable).
pub latest_version: Option<String>,
/// Short package description.
pub description: String,
/// CVE identifiers associated with this package.
#[serde(default)]
pub cve_ids: Vec<String>,
}
/// Payload returned by `GET /api/v1/packages`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PackagesData {
/// List of packages matching the query filters.
pub packages: Vec<Package>,
/// Total count of matching packages.
pub total: u64,
}
// ============================================================
// GET /api/v1/patches
// ============================================================
/// A single available patch.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Patch {
/// Package / patch name.
pub name: String,
/// Currently installed version.
pub current_version: String,
/// Version available after applying this patch.
pub available_version: String,
/// Severity level (e.g. `"critical"`, `"high"`, `"medium"`, `"low"`).
pub severity: String,
/// Human-readable description of the patch.
pub description: String,
/// CVE identifiers addressed by this patch.
#[serde(default)]
pub cve_ids: Vec<String>,
/// Whether applying this patch requires a system reboot.
pub requires_reboot: bool,
}
/// Payload returned by `GET /api/v1/patches`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PatchesData {
/// List of available patches.
pub patches: Vec<Patch>,
/// Total patch count.
pub total: u64,
/// Number of patches classified as security updates.
pub security_updates: u64,
/// Whether any patch in the list requires a reboot.
pub requires_reboot: bool,
}
// ============================================================
// POST /api/v1/patches/apply
// ============================================================
/// Request body for `POST /api/v1/patches/apply`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplyPatchesRequest {
/// Package names to apply. Empty = apply all available patches.
pub packages: Vec<String>,
/// If true, allow automatic reboot after patching if required.
pub allow_reboot: bool,
}
/// Response from `POST /api/v1/patches/apply`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApplyPatchesResponse {
/// Agent-assigned async job ID for status polling.
pub job_id: String,
/// Initial status: typically `"running"` or `"queued"`.
pub status: String,
}
// ============================================================
// GET /api/v1/jobs/{id}
// ============================================================
/// Status of an async agent job returned by `GET /api/v1/jobs/{id}`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentJobStatus {
pub job_id: String,
/// Current status: `"running"`, `"succeeded"`, `"failed"`, or `"cancelled"`.
pub status: String,
pub progress_percent: Option<u8>,
pub output: Option<String>,
pub error: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
// ============================================================
// POST /api/v1/jobs/{id}/rollback
// ============================================================
/// Response from `POST /api/v1/jobs/{id}/rollback`.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RollbackResponse {
pub job_id: String,
pub status: String,
}