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
365 lines
12 KiB
Rust
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));
|
|
}
|
|
}
|