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

@ -7,6 +7,7 @@
//! GET /api/v1/hosts/{id}/groups — list groups for host
//! POST /api/v1/hosts/{id}/groups — assign host to group
//! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group
//! POST /api/v1/hosts/{id}/refresh — queue on-demand refresh (operator+)
use axum::{
extract::{Path, Query, State},
@ -34,6 +35,7 @@ pub fn router() -> Router<AppState> {
.route("/:id", get(get_host).delete(remove_host))
.route("/:id/groups", get(list_host_groups).post(add_host_to_group))
.route("/:id/groups/:group_id", delete(remove_host_from_group))
.route("/:id/refresh", post(refresh_host))
}
// ── Query params ─────────────────────────────────────────────────────────────
@ -470,3 +472,56 @@ async fn resolve_fqdn(fqdn: &str) -> Result<String, String> {
_ => Err(format!("Failed to resolve FQDN: {fqdn}")),
}
}
// ── POST /api/v1/hosts/:id/refresh ───────────────────────────────────────────
/// Queue an on-demand health + patch refresh for a single host.
///
/// Sends a PostgreSQL NOTIFY on the `refresh_requested` channel; the
/// pm-worker refresh listener picks this up and polls the host immediately.
/// Requires Operator or Admin role (any authenticated user).
async fn refresh_host(
State(state): State<AppState>,
_auth: AuthUser,
Path(id): Path<Uuid>,
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
// Verify the host exists.
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hosts WHERE id = $1)")
.bind(id)
.fetch_one(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, %id, "refresh_host: db error checking host existence");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
if !exists {
return Err((
StatusCode::NOT_FOUND,
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
));
}
// NOTIFY the worker's refresh listener.
sqlx::query("SELECT pg_notify('refresh_requested', $1)")
.bind(id.to_string())
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, %id, "refresh_host: pg_notify failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Failed to queue refresh" } })),
)
})?;
tracing::info!(%id, "On-demand refresh queued");
Ok((
StatusCode::ACCEPTED,
Json(json!({ "message": "Refresh queued" })),
))
}