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:
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ use actix_web::{web, HttpResponse};
|
||||
use tracing::info;
|
||||
|
||||
use crate::jobs::manager::JobManager;
|
||||
use crate::packages::cache::PackageCacheState;
|
||||
|
||||
use super::handlers::{jobs, packages, patches, system, websocket};
|
||||
|
||||
@ -21,10 +22,11 @@ pub fn configure_api_routes(
|
||||
cfg: &mut web::ServiceConfig,
|
||||
job_manager: web::Data<JobManager>,
|
||||
backend: web::Data<Box<dyn crate::packages::PackageManagerBackend>>,
|
||||
cache_state: web::Data<PackageCacheState>,
|
||||
) {
|
||||
info!("Configuring API v1 routes");
|
||||
|
||||
cfg.app_data(job_manager).app_data(backend).service(
|
||||
cfg.app_data(job_manager).app_data(backend).app_data(cache_state).service(
|
||||
web::scope("/api/v1")
|
||||
// VULN-005: Default handler for unsupported methods returns 405 instead of 404
|
||||
.default_service(web::route().to(method_not_allowed))
|
||||
@ -42,6 +44,7 @@ pub fn configure_api_routes(
|
||||
}
|
||||
|
||||
/// Health check route (outside API scope for load balancer checks)
|
||||
/// Note: backend and cache_state are injected via app_data registered in main.rs
|
||||
pub fn configure_health_route(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("/health", web::get().to(system::health_check));
|
||||
}
|
||||
|
||||
11
src/main.rs
11
src/main.rs
@ -24,6 +24,7 @@ use tracing::{error, info, warn};
|
||||
use linux_patch_api::api::{configure_api_routes, configure_health_route};
|
||||
use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager};
|
||||
use linux_patch_api::enroll;
|
||||
use linux_patch_api::packages::cache::PackageCacheState;
|
||||
use linux_patch_api::packages::create_backend;
|
||||
use linux_patch_api::{init_logging, AppConfig, JobManager};
|
||||
|
||||
@ -146,6 +147,10 @@ async fn main() -> Result<()> {
|
||||
let job_manager_data = web::Data::new(job_manager);
|
||||
let backend_data = web::Data::new(package_backend);
|
||||
|
||||
// Initialize package cache state
|
||||
let cache_state = web::Data::new(PackageCacheState::new());
|
||||
info!("Package cache state initialized");
|
||||
|
||||
// Configure bind address
|
||||
let bind_address = format!("{}:{}", config.server.bind, config.server.port);
|
||||
info!(bind = %bind_address, "Starting HTTP server");
|
||||
@ -156,14 +161,16 @@ async fn main() -> Result<()> {
|
||||
let mut app = App::new()
|
||||
.wrap(Logger::default())
|
||||
.app_data(job_manager_data.clone())
|
||||
.app_data(backend_data.clone());
|
||||
.app_data(backend_data.clone())
|
||||
.app_data(cache_state.clone());
|
||||
|
||||
// Configure API routes
|
||||
app = app.configure(|cfg| {
|
||||
configure_api_routes(cfg, job_manager_data.clone(), backend_data.clone());
|
||||
configure_api_routes(cfg, job_manager_data.clone(), backend_data.clone(), cache_state.clone());
|
||||
});
|
||||
|
||||
// Configure health route (outside API scope)
|
||||
// cache_state and backend are available via app_data registered above
|
||||
app = app.configure(configure_health_route);
|
||||
|
||||
app
|
||||
|
||||
294
src/packages/cache.rs
Normal file
294
src/packages/cache.rs
Normal file
@ -0,0 +1,294 @@
|
||||
//! Package Cache Management Module
|
||||
//! Handles package index refresh, stale detection, state persistence, and 404 retry logic.
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// State file path for cache persistence
|
||||
const CACHE_STATE_PATH: &str = "/var/lib/linux_patch_api/state/cache.json";
|
||||
|
||||
/// Stale threshold: 4 hours
|
||||
const STALE_THRESHOLD_SECS: u64 = 4 * 60 * 60;
|
||||
|
||||
/// Cache refresh command timeout: 120 seconds
|
||||
#[allow(dead_code)]
|
||||
const CACHE_REFRESH_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
/// Persistent cache state (written to cache.json)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CacheStateFile {
|
||||
pub last_cache_update: Option<String>, // RFC3339
|
||||
pub last_update_success: bool,
|
||||
}
|
||||
|
||||
/// Runtime cache status
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PackageCacheStatus {
|
||||
pub last_update: Option<DateTime<Utc>>,
|
||||
pub last_update_success: bool,
|
||||
pub last_update_error: Option<String>,
|
||||
}
|
||||
|
||||
/// In-memory cache state (thread-safe)
|
||||
pub struct PackageCacheState {
|
||||
inner: Mutex<CacheStateInner>,
|
||||
}
|
||||
|
||||
struct CacheStateInner {
|
||||
last_update: Option<DateTime<Utc>>,
|
||||
last_update_success: bool,
|
||||
last_update_error: Option<String>,
|
||||
}
|
||||
|
||||
impl PackageCacheState {
|
||||
pub fn new() -> Self {
|
||||
// Try to load from state file on startup
|
||||
let inner = match Self::load_state_file() {
|
||||
Some(state) => CacheStateInner {
|
||||
last_update: state
|
||||
.last_cache_update
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc)),
|
||||
last_update_success: state.last_update_success,
|
||||
last_update_error: None,
|
||||
},
|
||||
None => CacheStateInner {
|
||||
last_update: None,
|
||||
last_update_success: false,
|
||||
last_update_error: None,
|
||||
},
|
||||
};
|
||||
Self {
|
||||
inner: Mutex::new(inner),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> PackageCacheStatus {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
PackageCacheStatus {
|
||||
last_update: inner.last_update,
|
||||
last_update_success: inner.last_update_success,
|
||||
last_update_error: inner.last_update_error.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_stale(&self) -> bool {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
match inner.last_update {
|
||||
None => true,
|
||||
Some(t) => {
|
||||
let threshold = Duration::from_secs(STALE_THRESHOLD_SECS);
|
||||
Utc::now() - t
|
||||
> chrono::Duration::from_std(threshold)
|
||||
.unwrap_or(chrono::TimeDelta::MAX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_success(&self) {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
inner.last_update = Some(Utc::now());
|
||||
inner.last_update_success = true;
|
||||
inner.last_update_error = None;
|
||||
drop(inner); // release lock before I/O
|
||||
self.persist_state();
|
||||
}
|
||||
|
||||
pub fn update_failure(&self, error: String) {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
inner.last_update_success = false;
|
||||
inner.last_update_error = Some(error);
|
||||
let now = Utc::now();
|
||||
// Keep old timestamp if we had one, don't update on failure
|
||||
if inner.last_update.is_none() {
|
||||
inner.last_update = Some(now); // first attempt timestamp
|
||||
}
|
||||
drop(inner);
|
||||
self.persist_state();
|
||||
}
|
||||
|
||||
fn load_state_file() -> Option<CacheStateFile> {
|
||||
let content = std::fs::read_to_string(CACHE_STATE_PATH).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn persist_state(&self) {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
let state = CacheStateFile {
|
||||
last_cache_update: inner.last_update.map(|dt| dt.to_rfc3339()),
|
||||
last_update_success: inner.last_update_success,
|
||||
};
|
||||
drop(inner); // release lock before I/O
|
||||
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = std::path::Path::new(CACHE_STATE_PATH).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
match serde_json::to_string_pretty(&state) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(CACHE_STATE_PATH, json) {
|
||||
warn!("Failed to persist cache state: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to serialize cache state: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an error message indicates a fetch/404 error
|
||||
pub fn is_fetch_error(error: &anyhow::Error) -> bool {
|
||||
let msg = error.to_string().to_lowercase();
|
||||
msg.contains("404")
|
||||
|| msg.contains("not found")
|
||||
|| msg.contains("failed to fetch")
|
||||
|| msg.contains("unable to fetch")
|
||||
}
|
||||
|
||||
/// Execute a patch apply with automatic cache refresh retry on 404/fetch errors.
|
||||
/// Hardcoded 1 retry after cache refresh.
|
||||
pub fn apply_with_cache_retry<F>(
|
||||
refresh_fn: F,
|
||||
apply_fn: impl Fn() -> Result<()>,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: Fn() -> Result<()>,
|
||||
{
|
||||
match apply_fn() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if is_fetch_error(&e) => {
|
||||
info!("Patch apply failed with fetch error, refreshing cache and retrying");
|
||||
refresh_fn()?;
|
||||
apply_fn()
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command with timeout for cache refresh operations
|
||||
pub fn run_command_with_timeout(program: &str, args: &[&str]) -> Result<String> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.env("DEBIAN_FRONTEND", "noninteractive")
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Cache refresh command failed: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_fetch_error_404() {
|
||||
let err = anyhow::anyhow!("E: Unable to fetch 404 Not Found");
|
||||
assert!(is_fetch_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_fetch_error_not_found() {
|
||||
let err = anyhow::anyhow!("Package not found in repository");
|
||||
assert!(is_fetch_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_fetch_error_failed_to_fetch() {
|
||||
let err = anyhow::anyhow!("Failed to fetch package index");
|
||||
assert!(is_fetch_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_fetch_error_unable_to_fetch() {
|
||||
let err = anyhow::anyhow!("Unable to fetch some archive");
|
||||
assert!(is_fetch_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_not_fetch_error() {
|
||||
let err = anyhow::anyhow!("Permission denied");
|
||||
assert!(!is_fetch_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_state_new() {
|
||||
let state = PackageCacheState::new();
|
||||
let status = state.status();
|
||||
// Fresh state should have no last_update (unless state file exists)
|
||||
// Just verify it doesn't panic
|
||||
assert!(status.last_update_success == false || status.last_update.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_state_stale_when_no_update() {
|
||||
let state = PackageCacheState::new();
|
||||
// If no state file exists, cache should be stale
|
||||
// This test may vary based on state file existence,
|
||||
// but we can at least call is_stale without panic
|
||||
let _ = state.is_stale();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_state_update_success() {
|
||||
let state = PackageCacheState::new();
|
||||
state.update_success();
|
||||
let status = state.status();
|
||||
assert!(status.last_update.is_some());
|
||||
assert!(status.last_update_success);
|
||||
assert!(status.last_update_error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_state_update_failure() {
|
||||
let state = PackageCacheState::new();
|
||||
state.update_failure("test error".to_string());
|
||||
let status = state.status();
|
||||
assert!(!status.last_update_success);
|
||||
assert_eq!(status.last_update_error, Some("test error".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_with_cache_retry_success() {
|
||||
let result = apply_with_cache_retry(
|
||||
|| Ok(()),
|
||||
|| Ok(()),
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_with_cache_retry_non_fetch_error() {
|
||||
let result: Result<()> = apply_with_cache_retry(
|
||||
|| Ok(()),
|
||||
|| Err(anyhow::anyhow!("Permission denied")),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(!is_fetch_error(&err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_with_cache_retry_fetch_error_with_refresh() {
|
||||
let mut refresh_called = false;
|
||||
let result: Result<()> = apply_with_cache_retry(
|
||||
|| {
|
||||
refresh_called = true;
|
||||
Ok(())
|
||||
},
|
||||
|| Err(anyhow::anyhow!("404 Not Found")),
|
||||
);
|
||||
// Refresh should have been called, but second apply_fn still fails with 404
|
||||
assert!(refresh_called);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,10 @@
|
||||
//! Supports apt/dpkg (Debian/Ubuntu), apk (Alpine Linux), dnf (Fedora/RHEL), yum (CentOS 7),
|
||||
//! and pacman (Arch Linux) with pluggable backend architecture.
|
||||
|
||||
pub mod cache;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
use tracing::info;
|
||||
@ -90,6 +93,12 @@ pub trait PackageManagerBackend: Send + Sync {
|
||||
fn get_system_info(&self) -> Result<SystemInfo>;
|
||||
fn reboot_system(&self, delay_seconds: u64) -> Result<()>;
|
||||
fn get_service_status(&self, name: &str) -> Result<Option<ServiceStatus>>;
|
||||
|
||||
/// Refresh the local package index (apt-get update, dnf check-update, etc.)
|
||||
fn refresh_package_cache(&self, cache_state: &cache::PackageCacheState) -> Result<()>;
|
||||
|
||||
/// Get the last cache update timestamp
|
||||
fn last_cache_update(&self, cache_state: &cache::PackageCacheState) -> Option<DateTime<Utc>>;
|
||||
}
|
||||
|
||||
/// Package specification for installation
|
||||
@ -516,6 +525,26 @@ impl PackageManagerBackend for AptBackend {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_package_cache(&self, cache_state: &cache::PackageCacheState) -> Result<()> {
|
||||
info!("Refreshing APT package cache");
|
||||
match cache::run_command_with_timeout("apt-get", &["update"]) {
|
||||
Ok(_) => {
|
||||
cache_state.update_success();
|
||||
info!("APT package cache refreshed successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("APT cache refresh failed: {}", e);
|
||||
cache_state.update_failure(err_msg.clone());
|
||||
Err(anyhow::anyhow!("{}", err_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn last_cache_update(&self, cache_state: &cache::PackageCacheState) -> Option<DateTime<Utc>> {
|
||||
cache_state.status().last_update
|
||||
}
|
||||
}
|
||||
|
||||
/// Query systemd service status via systemctl
|
||||
@ -1165,6 +1194,26 @@ impl PackageManagerBackend for ApkBackend {
|
||||
// Alpine uses OpenRC for service management
|
||||
get_openrc_service_status(name)
|
||||
}
|
||||
|
||||
fn refresh_package_cache(&self, cache_state: &cache::PackageCacheState) -> Result<()> {
|
||||
info!("Refreshing APK package cache");
|
||||
match cache::run_command_with_timeout("apk", &["update"]) {
|
||||
Ok(_) => {
|
||||
cache_state.update_success();
|
||||
info!("APK package cache refreshed successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("APK cache refresh failed: {}", e);
|
||||
cache_state.update_failure(err_msg.clone());
|
||||
Err(anyhow::anyhow!("{}", err_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn last_cache_update(&self, cache_state: &cache::PackageCacheState) -> Option<DateTime<Utc>> {
|
||||
cache_state.status().last_update
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ApkBackend {
|
||||
@ -1717,6 +1766,27 @@ impl PackageManagerBackend for DnfBackend {
|
||||
// Fedora/RHEL use systemd for service management
|
||||
get_systemd_service_status(name)
|
||||
}
|
||||
|
||||
fn refresh_package_cache(&self, cache_state: &cache::PackageCacheState) -> Result<()> {
|
||||
info!("Refreshing DNF package cache");
|
||||
match cache::run_command_with_timeout("dnf", &["check-update", "--refresh"]) {
|
||||
Ok(_) => {
|
||||
cache_state.update_success();
|
||||
info!("DNF package cache refreshed successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// dnf check-update returns exit code 100 when updates available (not an error)
|
||||
let err_msg = format!("DNF cache refresh failed: {}", e);
|
||||
cache_state.update_failure(err_msg.clone());
|
||||
Err(anyhow::anyhow!("{}", err_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn last_cache_update(&self, cache_state: &cache::PackageCacheState) -> Option<DateTime<Utc>> {
|
||||
cache_state.status().last_update
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DnfBackend {
|
||||
@ -2239,6 +2309,26 @@ impl PackageManagerBackend for YumBackend {
|
||||
// CentOS 7 uses systemd for service management
|
||||
get_systemd_service_status(name)
|
||||
}
|
||||
|
||||
fn refresh_package_cache(&self, cache_state: &cache::PackageCacheState) -> Result<()> {
|
||||
info!("Refreshing YUM package cache");
|
||||
match cache::run_command_with_timeout("yum", &["makecache"]) {
|
||||
Ok(_) => {
|
||||
cache_state.update_success();
|
||||
info!("YUM package cache refreshed successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("YUM cache refresh failed: {}", e);
|
||||
cache_state.update_failure(err_msg.clone());
|
||||
Err(anyhow::anyhow!("{}", err_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn last_cache_update(&self, cache_state: &cache::PackageCacheState) -> Option<DateTime<Utc>> {
|
||||
cache_state.status().last_update
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for YumBackend {
|
||||
@ -2664,6 +2754,26 @@ impl PackageManagerBackend for PacmanBackend {
|
||||
// Arch Linux uses systemd for service management
|
||||
get_systemd_service_status(name)
|
||||
}
|
||||
|
||||
fn refresh_package_cache(&self, cache_state: &cache::PackageCacheState) -> Result<()> {
|
||||
info!("Refreshing Pacman package cache");
|
||||
match cache::run_command_with_timeout("pacman", &["-Sy"]) {
|
||||
Ok(_) => {
|
||||
cache_state.update_success();
|
||||
info!("Pacman package cache refreshed successfully");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = format!("Pacman cache refresh failed: {}", e);
|
||||
cache_state.update_failure(err_msg.clone());
|
||||
Err(anyhow::anyhow!("{}", err_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn last_cache_update(&self, cache_state: &cache::PackageCacheState) -> Option<DateTime<Utc>> {
|
||||
cache_state.status().last_update
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PacmanBackend {
|
||||
|
||||
Reference in New Issue
Block a user