Private
Public Access
1
0

fix: add package cache refresh before apply and on health check

- New src/packages/cache.rs module with PackageCacheState, stale detection,
  state persistence, 404 retry logic
- Add refresh_package_cache() and last_cache_update() to PackageManagerBackend
  trait, implemented on all 5 backends (APT, DNF, YUM, APK, Pacman)
- Health check now reports last_cache_update and cache_status fields,
  triggers cache refresh if stale (>4h), returns degraded on failure
- Patch apply jobs now force cache refresh before applying patches,
  with 404/fetch error retry (1 retry after cache refresh)
- Cache state persists to /var/lib/linux_patch_api/state/cache.json
- Version bump to 1.1.17
- Update ARCHITECTURE.md and REQUIREMENTS.md (FR-007)

Closes: #2
This commit is contained in:
2026-05-27 14:33:12 -05:00
parent 7f5b0c2313
commit 135c91d256
12 changed files with 944 additions and 15 deletions

View File

@ -81,6 +81,7 @@ pub async fn apply_patches(
body: web::Json<PatchApplyRequest>,
backend: web::Data<Box<dyn PackageManagerBackend>>,
job_manager: web::Data<JobManager>,
cache_state: web::Data<crate::packages::cache::PackageCacheState>,
_req: HttpRequest,
) -> impl Responder {
let request_id = Uuid::new_v4().to_string();
@ -104,6 +105,7 @@ pub async fn apply_patches(
// Spawn background task to execute the patching
let backend_clone = backend.clone();
let job_manager_clone = job_manager.clone();
let cache_state_clone = cache_state.clone();
let request = body.clone();
tokio::spawn(async move {
@ -122,8 +124,39 @@ pub async fn apply_patches(
.add_job_log(&job_id_clone, "Job started".to_string())
.await;
// Execute patching
match backend_clone.apply_patches(request.packages.as_deref()) {
// MANDATORY: Refresh package cache before applying patches
let _ = job_manager_clone
.update_job(&job_id_clone, JobStatus::Running, Some(0), Some("Refreshing package index...".to_string()))
.await;
let _ = job_manager_clone
.add_job_log(&job_id_clone, "Refreshing package cache...".to_string())
.await;
match backend_clone.refresh_package_cache(&cache_state_clone) {
Ok(_) => {
let _ = job_manager_clone
.add_job_log(&job_id_clone, "Package cache refreshed successfully".to_string())
.await;
let _ = job_manager_clone
.update_job(&job_id_clone, JobStatus::Running, Some(10), Some("Cache refreshed, applying patches...".to_string()))
.await;
}
Err(e) => {
let err_msg = format!("Package cache refresh failed: {}", e);
error!(job_id = %job_id_clone, error = %e, "Cache refresh failed");
let _ = job_manager_clone
.add_job_log(&job_id_clone, err_msg.clone())
.await;
let _ = job_manager_clone.fail_job(&job_id_clone, err_msg).await;
return; // Exit the spawned task
}
}
// Execute patching with 404 retry
let packages_ref = request.packages.as_deref();
let apply_result = backend_clone.apply_patches(packages_ref);
match apply_result {
Ok(_) => {
let _ = job_manager_clone.complete_job(&job_id_clone).await;
info!(job_id = %job_id_clone, "Patch application completed");
@ -157,10 +190,67 @@ pub async fn apply_patches(
}
}
}
Err(e) => {
Err(e) if crate::packages::cache::is_fetch_error(&e) => {
// 404/fetch error: refresh cache and retry once
info!(job_id = %job_id_clone, "Patch apply failed with fetch error, refreshing cache and retrying");
let _ = job_manager_clone
.fail_job(&job_id_clone, e.to_string())
.add_job_log(&job_id_clone, "Fetch error detected, refreshing cache and retrying...".to_string())
.await;
match backend_clone.refresh_package_cache(&cache_state_clone) {
Ok(_) => {
let _ = job_manager_clone
.add_job_log(&job_id_clone, "Cache refreshed, retrying patch apply...".to_string())
.await;
}
Err(refresh_err) => {
let err_msg = format!("Cache refresh on retry failed: {}", refresh_err);
let _ = job_manager_clone.fail_job(&job_id_clone, err_msg).await;
error!(job_id = %job_id_clone, error = %refresh_err, "Cache refresh on retry failed");
return;
}
}
// Retry the apply
match backend_clone.apply_patches(packages_ref) {
Ok(_) => {
let _ = job_manager_clone.complete_job(&job_id_clone).await;
info!(job_id = %job_id_clone, "Patch application completed after retry");
// Handle reboot if requested
if request.reboot {
let _ = job_manager_clone
.add_job_log(
&job_id_clone,
format!(
"Reboot scheduled in {} seconds",
request.reboot_delay_seconds
),
)
.await;
match backend_clone.reboot_system(request.reboot_delay_seconds) {
Ok(_) => {
let _ = job_manager_clone
.add_job_log(&job_id_clone, "Reboot command executed".to_string())
.await;
}
Err(e) => {
let _ = job_manager_clone
.add_job_log(&job_id_clone, format!("Reboot failed: {}", e))
.await;
}
}
}
}
Err(retry_err) => {
let _ = job_manager_clone.fail_job(&job_id_clone, retry_err.to_string()).await;
error!(job_id = %job_id_clone, error = %retry_err, "Patch application failed after retry");
}
}
}
Err(e) => {
// Non-fetch error: fail immediately
let _ = job_manager_clone.fail_job(&job_id_clone, e.to_string()).await;
error!(job_id = %job_id_clone, error = %e, "Patch application failed");
}
}

View File

@ -42,9 +42,11 @@ pub struct SystemInfoData {
/// Health check response data
#[derive(Debug, Serialize)]
pub struct HealthData {
pub status: String,
pub status: String, // "healthy" or "degraded"
pub uptime_seconds: u64,
pub version: String,
pub last_cache_update: Option<String>, // RFC3339 timestamp
pub cache_status: String, // "fresh", "stale", "unknown", "failed"
}
/// Service status response data
@ -108,7 +110,11 @@ pub async fn get_system_info(
}
/// Health check endpoint
pub async fn health_check(_req: HttpRequest) -> impl Responder {
pub async fn health_check(
backend: web::Data<Box<dyn PackageManagerBackend>>,
cache_state: web::Data<crate::packages::cache::PackageCacheState>,
_req: HttpRequest,
) -> impl Responder {
let _request_id = Uuid::new_v4().to_string();
let _timestamp = Utc::now().to_rfc3339();
@ -126,10 +132,29 @@ pub async fn health_check(_req: HttpRequest) -> impl Responder {
let version = env!("CARGO_PKG_VERSION").to_string();
// Check cache status and refresh if stale
let cache_status_val = cache_state.status();
let (status, cache_status_str, last_cache_update) = if cache_state.is_stale() {
match backend.refresh_package_cache(&cache_state) {
Ok(_) => {
let updated = cache_state.status();
("healthy".to_string(), "fresh".to_string(), updated.last_update.map(|dt| dt.to_rfc3339()))
}
Err(e) => {
error!("Health check cache refresh failed: {}", e);
("degraded".to_string(), "failed".to_string(), cache_status_val.last_update.map(|dt| dt.to_rfc3339()))
}
}
} else {
("healthy".to_string(), "fresh".to_string(), cache_status_val.last_update.map(|dt| dt.to_rfc3339()))
};
let response = ApiResponse::success(HealthData {
status: "healthy".to_string(),
status,
uptime_seconds,
version,
last_cache_update,
cache_status: cache_status_str,
});
HttpResponse::Ok().json(response)
@ -317,6 +342,8 @@ pub fn configure_routes(cfg: &mut web::ServiceConfig) {
.route("/services/{name}", web::get().to(get_service_status)),
)
.route("/health", web::get().to(health_check));
// Note: health_check receives backend and cache_state via app_data injection
// They are registered in routes.rs and main.rs as web::Data
}
#[cfg(test)]
@ -345,9 +372,13 @@ mod tests {
status: "healthy".to_string(),
uptime_seconds: 12345,
version: "0.1.0".to_string(),
last_cache_update: Some("2026-05-27T14:00:00+00:00".to_string()),
cache_status: "fresh".to_string(),
};
let json = serde_json::to_string(&health).unwrap();
assert!(json.contains("healthy"));
assert!(json.contains("12345"));
assert!(json.contains("fresh"));
assert!(json.contains("last_cache_update"));
}
}