Private
Public Access
1
0
Files
linux_patch_api/src/api/handlers/jobs.rs
Echo f1a76e33f3
Some checks failed
CI/CD Pipeline / Code Format (push) Failing after 12s
CI/CD Pipeline / Clippy Lints (push) Failing after 5m34s
CI/CD Pipeline / Unit Tests (push) Failing after 10m51s
CI/CD Pipeline / Build Debian Package (push) Failing after 1s
CI/CD Pipeline / Build RPM Package (push) Failing after 1s
CI/CD Pipeline / Build Alpine Package (push) Failing after 2s
CI/CD Pipeline / Build Arch Package (push) Failing after 2s
CI/CD Pipeline / Create Release (push) Has been skipped
CI/CD Pipeline / Security Audit (push) Failing after 15m40s
Fix clippy warnings: remove unused imports/variables/functions, derive Default, fix comparisons
2026-04-12 15:23:02 +00:00

365 lines
12 KiB
Rust

//! Job Management API Handlers
//!
//! Implements REST endpoints for job management operations:
//! - GET /api/v1/jobs - List all jobs
//! - GET /api/v1/jobs/{id} - Get job status/details
//! - POST /api/v1/jobs/{id}/rollback - Rollback failed job
//! - DELETE /api/v1/jobs/{id} - Clear completed job from history
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::jobs::manager::{Job, JobManager, JobStatus};
use super::packages::ApiResponse;
/// Job list response data
#[derive(Debug, Serialize)]
pub struct JobListData {
pub jobs: Vec<JobSummary>,
pub total: usize,
}
/// Job summary for list view
#[derive(Debug, Serialize)]
pub struct JobSummary {
pub job_id: String,
pub operation: String,
pub status: String,
pub created_at: String,
pub completed_at: Option<String>,
pub packages: Vec<String>,
}
/// Job detail response data
#[derive(Debug, Serialize)]
pub struct JobDetailData {
pub job_id: String,
pub operation: String,
pub status: String,
pub progress: u8,
pub message: String,
pub created_at: String,
pub completed_at: Option<String>,
pub packages: Vec<String>,
pub logs: Vec<String>,
pub error: Option<String>,
pub rollback_job_id: Option<String>,
pub exclusive_mode: bool,
}
/// Query parameters for job listing
#[derive(Debug, Deserialize)]
pub struct JobListQuery {
pub status: Option<String>,
pub limit: Option<usize>,
}
impl JobSummary {
pub fn from_job(job: &Job) -> Self {
Self {
job_id: job.id.to_string(),
operation: format!("{:?}", job.operation).to_lowercase(),
status: format!("{:?}", job.status).to_lowercase(),
created_at: job.created_at.to_rfc3339(),
completed_at: job.completed_at.map(|t| t.to_rfc3339()),
packages: job.packages.clone(),
}
}
}
impl JobDetailData {
pub fn from_job(job: &Job) -> Self {
Self {
job_id: job.id.to_string(),
operation: format!("{:?}", job.operation).to_lowercase(),
status: format!("{:?}", job.status).to_lowercase(),
progress: job.progress,
message: job.message.clone(),
created_at: job.created_at.to_rfc3339(),
completed_at: job.completed_at.map(|t| t.to_rfc3339()),
packages: job.packages.clone(),
logs: job.logs.clone(),
error: job.error.clone(),
rollback_job_id: job.rollback_job_id.map(|id| id.to_string()),
exclusive_mode: job.exclusive_mode,
}
}
}
/// Parse job status from string
fn parse_job_status(status_str: &str) -> Option<JobStatus> {
match status_str.to_lowercase().as_str() {
"pending" => Some(JobStatus::Pending),
"running" => Some(JobStatus::Running),
"completed" => Some(JobStatus::Completed),
"failed" => Some(JobStatus::Failed),
"cancelled" => Some(JobStatus::Cancelled),
"timedout" => Some(JobStatus::TimedOut),
_ => None,
}
}
/// List all jobs with optional filtering
pub async fn list_jobs(
query: web::Query<JobListQuery>,
job_manager: web::Data<JobManager>,
_req: HttpRequest,
) -> impl Responder {
let request_id = Uuid::new_v4().to_string();
let _timestamp = Utc::now().to_rfc3339();
let status_filter = query.status.as_ref().and_then(|s| parse_job_status(s));
let limit = query.limit.unwrap_or(50);
info!(
request_id = %request_id,
status_filter = ?status_filter,
limit = limit,
"Listing jobs"
);
let jobs = job_manager.list_jobs(status_filter, limit).await;
let total = jobs.len();
let job_summaries: Vec<JobSummary> = jobs.iter().map(JobSummary::from_job).collect();
let response = ApiResponse::success(JobListData {
jobs: job_summaries,
total,
});
HttpResponse::Ok().json(response)
}
/// Get specific job status and details
pub async fn get_job(
path: web::Path<String>,
job_manager: web::Data<JobManager>,
_req: HttpRequest,
) -> impl Responder {
let request_id = Uuid::new_v4().to_string();
let _timestamp = Utc::now().to_rfc3339();
let job_id_str = path.into_inner();
info!(request_id = %request_id, job_id = %job_id_str, "Getting job details");
// Parse job ID
let job_id = match Uuid::parse_str(&job_id_str) {
Ok(id) => id,
Err(_) => {
let response = ApiResponse::<()>::error(
"INVALID_JOB_ID",
"Invalid job ID format. Expected UUID.",
None,
false,
);
return HttpResponse::BadRequest().json(response);
}
};
match job_manager.get_job(&job_id).await {
Some(job) => {
let response = ApiResponse::success(JobDetailData::from_job(&job));
HttpResponse::Ok().json(response)
}
None => {
warn!(request_id = %request_id, job_id = %job_id_str, "Job not found");
let response = ApiResponse::<()>::error(
"JOB_NOT_FOUND",
&format!("Job '{}' not found", job_id_str),
None,
false,
);
HttpResponse::NotFound().json(response)
}
}
}
/// Rollback a failed/completed job (async operation)
pub async fn rollback_job(
path: web::Path<String>,
job_manager: web::Data<JobManager>,
_req: HttpRequest,
) -> impl Responder {
let request_id = Uuid::new_v4().to_string();
let _timestamp = Utc::now().to_rfc3339();
let job_id_str = path.into_inner();
info!(request_id = %request_id, job_id = %job_id_str, "Initiating job rollback");
// Parse job ID
let job_id = match Uuid::parse_str(&job_id_str) {
Ok(id) => id,
Err(_) => {
let response = ApiResponse::<()>::error(
"INVALID_JOB_ID",
"Invalid job ID format. Expected UUID.",
None,
false,
);
return HttpResponse::BadRequest().json(response);
}
};
match job_manager.create_rollback_job(&job_id).await {
Ok(Some(rollback_job_id)) => {
info!(
request_id = %request_id,
original_job_id = %job_id_str,
rollback_job_id = %rollback_job_id,
"Rollback job created"
);
let response = ApiResponse::success(serde_json::json!({
"job_id": rollback_job_id.to_string(),
"status": "pending",
"operation": "rollback",
"original_job_id": job_id_str,
"exclusive_mode": true,
}));
HttpResponse::Accepted().json(response)
}
Ok(None) => {
warn!(request_id = %request_id, job_id = %job_id_str, "Job not eligible for rollback");
let response = ApiResponse::<()>::error(
"ROLLBACK_NOT_ALLOWED",
"Job is not eligible for rollback. Only failed or completed jobs can be rolled back.",
Some(serde_json::json!({"job_id": job_id_str})),
false,
);
HttpResponse::BadRequest().json(response)
}
Err(e) => {
error!(request_id = %request_id, job_id = %job_id_str, error = %e, "Failed to create rollback job");
let response = ApiResponse::<()>::error(
"JOB_CREATE_ERROR",
&format!("Failed to create rollback job: {}", e),
None,
true,
);
HttpResponse::InternalServerError().json(response)
}
}
}
/// Delete a completed/failed job from history
pub async fn delete_job(
path: web::Path<String>,
job_manager: web::Data<JobManager>,
_req: HttpRequest,
) -> impl Responder {
let request_id = Uuid::new_v4().to_string();
let _timestamp = Utc::now().to_rfc3339();
let job_id_str = path.into_inner();
info!(request_id = %request_id, job_id = %job_id_str, "Deleting job from history");
// Parse job ID
let job_id = match Uuid::parse_str(&job_id_str) {
Ok(id) => id,
Err(_) => {
let response = ApiResponse::<()>::error(
"INVALID_JOB_ID",
"Invalid job ID format. Expected UUID.",
None,
false,
);
return HttpResponse::BadRequest().json(response);
}
};
match job_manager.delete_job(&job_id).await {
Ok(true) => {
info!(request_id = %request_id, job_id = %job_id_str, "Job deleted successfully");
let response = ApiResponse::success(serde_json::json!({
"deleted": true,
"job_id": job_id_str,
}));
HttpResponse::Ok().json(response)
}
Ok(false) => {
// Check if job exists but is not deletable
if let Some(job) = job_manager.get_job(&job_id).await {
warn!(
request_id = %request_id,
job_id = %job_id_str,
status = ?job.status,
"Cannot delete job - not in terminal state"
);
let response = ApiResponse::<()>::error(
"DELETE_NOT_ALLOWED",
"Cannot delete job that is not in a terminal state (completed/failed/cancelled).",
Some(serde_json::json!({"job_id": job_id_str, "status": format!("{:?}", job.status).to_lowercase()})),
false,
);
HttpResponse::Conflict().json(response)
} else {
warn!(request_id = %request_id, job_id = %job_id_str, "Job not found");
let response = ApiResponse::<()>::error(
"JOB_NOT_FOUND",
&format!("Job '{}' not found", job_id_str),
None,
false,
);
HttpResponse::NotFound().json(response)
}
}
Err(e) => {
error!(request_id = %request_id, job_id = %job_id_str, error = %e, "Failed to delete job");
let response = ApiResponse::<()>::error(
"JOB_DELETE_ERROR",
&format!("Failed to delete job: {}", e),
None,
true,
);
HttpResponse::InternalServerError().json(response)
}
}
}
/// Configure routes for job endpoints
pub fn configure_routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/jobs")
.route("", web::get().to(list_jobs))
.route("/{id}", web::get().to(get_job))
.route("/{id}/rollback", web::post().to(rollback_job))
.route("/{id}", web::delete().to(delete_job)),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_job_status() {
assert_eq!(parse_job_status("pending"), Some(JobStatus::Pending));
assert_eq!(parse_job_status("PENDING"), Some(JobStatus::Pending));
assert_eq!(parse_job_status("running"), Some(JobStatus::Running));
assert_eq!(parse_job_status("completed"), Some(JobStatus::Completed));
assert_eq!(parse_job_status("failed"), Some(JobStatus::Failed));
assert_eq!(parse_job_status("invalid"), None);
}
#[test]
fn test_job_list_query_default() {
let json = r#"{}"#;
let query: JobListQuery = serde_json::from_str(json).unwrap();
assert!(query.status.is_none());
assert!(query.limit.is_none());
}
#[test]
fn test_job_list_query_full() {
let json = r#"{"status": "running", "limit": 10}"#;
let query: JobListQuery = serde_json::from_str(json).unwrap();
assert_eq!(query.status, Some("running".to_string()));
assert_eq!(query.limit, Some(10));
}
}