v1.0.0 Release - All Phases Complete
Some checks failed
CI/CD Pipeline / Code Format (push) Has been cancelled
CI/CD Pipeline / Clippy Lints (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Security Audit (push) Has been cancelled
CI/CD Pipeline / Build Release (x86_64-unknown-linux-gnu) (push) Has been cancelled
CI/CD Pipeline / Build Ubuntu Package (push) Has been cancelled
Some checks failed
CI/CD Pipeline / Code Format (push) Has been cancelled
CI/CD Pipeline / Clippy Lints (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Security Audit (push) Has been cancelled
CI/CD Pipeline / Build Release (x86_64-unknown-linux-gnu) (push) Has been cancelled
CI/CD Pipeline / Build Ubuntu Package (push) Has been cancelled
Phase 2: Core API Development - 15 REST API endpoints (packages, patches, system, jobs, websocket) - mTLS authentication layer (src/auth/mtls.rs) - IP whitelist enforcement (src/auth/whitelist.rs) - Job manager with async operation support - WebSocket streaming for job status Phase 3: Security Hardening - Security testing: 16/16 tests passing - Fuzz testing: 21 tests, all findings resolved - Threat model validation (STRIDE matrix) - TLS binding fix (critical vulnerability resolved) - Security documentation complete Phase 4: Production Readiness - Performance benchmarking (all targets met) - Package creation (.deb/.rpm structures) - Documentation (README, API docs, deployment guide) - Security hardening (6 vulnerabilities fixed) Deliverables: - API_DOCUMENTATION.md (889 lines) - DEPLOYMENT_GUIDE.md (733 lines) - SECURITY.md (346 lines) - README.md (525 lines) - debian/ package structure - linux-patch-api.spec (RPM) - install.sh installer script - benches/api_benchmarks.rs - Multiple security/performance reports Security Status: 0 vulnerabilities remaining Test Coverage: 31 unit tests, 21 integration tests Build Status: Release optimized
This commit is contained in:
364
src/api/handlers/jobs.rs
Normal file
364
src/api/handlers/jobs.rs
Normal file
@ -0,0 +1,364 @@
|
||||
//! 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::{JobManager, JobOperation, JobStatus, Job};
|
||||
|
||||
use super::packages::{ApiResponse, JobResponseData};
|
||||
|
||||
/// 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user