diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2026-01-22 22:07:32 +0100 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2026-02-10 18:44:26 +0100 |
| commit | 064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (patch) | |
| tree | a2023f9ccd297ed8a41a3a0cc5699c2add09244d /src/cleanup.rs | |
witryna 0.1.0 — initial releasev0.1.0
Minimalist Git-based static site deployment orchestrator.
Webhook-triggered builds in Podman/Docker containers with atomic
symlink publishing, SIGHUP hot-reload, and zero-downtime deploys.
See README.md for usage, CHANGELOG.md for details.
Diffstat (limited to 'src/cleanup.rs')
| -rw-r--r-- | src/cleanup.rs | 467 |
1 files changed, 467 insertions, 0 deletions
diff --git a/src/cleanup.rs b/src/cleanup.rs new file mode 100644 index 0000000..ced8320 --- /dev/null +++ b/src/cleanup.rs @@ -0,0 +1,467 @@ +use anyhow::{Context as _, Result}; +use std::path::Path; +use tracing::{debug, info, warn}; + +/// Result of a cleanup operation. +#[derive(Debug, Default)] +pub struct CleanupResult { + /// Number of build directories removed. + pub builds_removed: u32, + /// Number of log files removed. + pub logs_removed: u32, +} + +/// Clean up old build directories and their corresponding log files. +/// +/// Keeps the `max_to_keep` most recent builds and removes older ones. +/// Also removes the corresponding log files for each removed build. +/// +/// # Arguments +/// * `base_dir` - Base witryna directory (e.g., /var/lib/witryna) +/// * `log_dir` - Log directory (e.g., /var/log/witryna) +/// * `site_name` - The site name +/// * `max_to_keep` - Maximum number of builds to keep (0 = keep all) +/// +/// # Errors +/// +/// Returns an error if the builds directory cannot be listed. Individual +/// removal failures are logged as warnings but do not cause the function +/// to return an error. +pub async fn cleanup_old_builds( + base_dir: &Path, + log_dir: &Path, + site_name: &str, + max_to_keep: u32, +) -> Result<CleanupResult> { + // If max_to_keep is 0, keep all builds + if max_to_keep == 0 { + debug!(%site_name, "max_builds_to_keep is 0, skipping cleanup"); + return Ok(CleanupResult::default()); + } + + let builds_dir = base_dir.join("builds").join(site_name); + let site_log_dir = log_dir.join(site_name); + + // Check if builds directory exists + if !builds_dir.exists() { + debug!(%site_name, "builds directory does not exist, skipping cleanup"); + return Ok(CleanupResult::default()); + } + + // List all build directories (excluding 'current' symlink) + let mut build_timestamps = list_build_timestamps(&builds_dir).await?; + + // Sort in descending order (newest first) + build_timestamps.sort_by(|a, b| b.cmp(a)); + + let mut result = CleanupResult::default(); + + // Calculate how many to remove + let to_remove = build_timestamps.len().saturating_sub(max_to_keep as usize); + if to_remove == 0 { + debug!(%site_name, count = build_timestamps.len(), max = max_to_keep, "no builds to remove"); + } + + // Remove oldest builds (they're at the end after reverse sort) + for timestamp in build_timestamps.iter().skip(max_to_keep as usize) { + let build_path = builds_dir.join(timestamp); + let log_path = site_log_dir.join(format!("{timestamp}.log")); + + // Remove build directory + match tokio::fs::remove_dir_all(&build_path).await { + Ok(()) => { + debug!(path = %build_path.display(), "removed old build"); + result.builds_removed += 1; + } + Err(e) => { + warn!(path = %build_path.display(), error = %e, "failed to remove old build"); + } + } + + // Remove corresponding log file (if exists) + if log_path.exists() { + match tokio::fs::remove_file(&log_path).await { + Ok(()) => { + debug!(path = %log_path.display(), "removed old log"); + result.logs_removed += 1; + } + Err(e) => { + warn!(path = %log_path.display(), error = %e, "failed to remove old log"); + } + } + } + + // Remove corresponding hook log file (if exists) + let hook_log_path = site_log_dir.join(format!("{timestamp}-hook.log")); + match tokio::fs::remove_file(&hook_log_path).await { + Ok(()) => { + debug!(path = %hook_log_path.display(), "removed old hook log"); + result.logs_removed += 1; + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Not every build has a hook — silently skip + } + Err(e) => { + warn!(path = %hook_log_path.display(), error = %e, "failed to remove old hook log"); + } + } + } + + // Remove orphaned temp files (crash recovery) + if site_log_dir.exists() + && let Ok(mut entries) = tokio::fs::read_dir(&site_log_dir).await + { + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + if name.to_string_lossy().ends_with(".tmp") { + let path = entry.path(); + match tokio::fs::remove_file(&path).await { + Ok(()) => { + debug!(path = %path.display(), "removed orphaned temp file"); + } + Err(e) => { + warn!(path = %path.display(), error = %e, "failed to remove orphaned temp file"); + } + } + } + } + } + + if result.builds_removed > 0 || result.logs_removed > 0 { + info!( + %site_name, + builds_removed = result.builds_removed, + logs_removed = result.logs_removed, + "cleanup completed" + ); + } + + Ok(result) +} + +/// List all build timestamps in a builds directory. +/// +/// Returns directory names that look like timestamps, excluding 'current' symlink. +async fn list_build_timestamps(builds_dir: &Path) -> Result<Vec<String>> { + let mut timestamps = Vec::new(); + + let mut entries = tokio::fs::read_dir(builds_dir) + .await + .with_context(|| format!("failed to read builds directory: {}", builds_dir.display()))?; + + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + + // Skip 'current' symlink and any other non-timestamp entries + if name_str == "current" { + continue; + } + + // Verify it's a directory (not a file or broken symlink) + let file_type = entry.file_type().await?; + if !file_type.is_dir() { + continue; + } + + // Basic timestamp format validation: YYYYMMDD-HHMMSS-... + if looks_like_timestamp(&name_str) { + timestamps.push(name_str.to_string()); + } + } + + Ok(timestamps) +} + +/// Check if a string looks like a valid timestamp format. +/// +/// Expected format: YYYYMMDD-HHMMSS-microseconds (e.g., 20260126-143000-123456) +fn looks_like_timestamp(s: &str) -> bool { + let parts: Vec<&str> = s.split('-').collect(); + let [date, time, micros, ..] = parts.as_slice() else { + return false; + }; + + // First part should be 8 digits (YYYYMMDD) + if date.len() != 8 || !date.chars().all(|c| c.is_ascii_digit()) { + return false; + } + + // Second part should be 6 digits (HHMMSS) + if time.len() != 6 || !time.chars().all(|c| c.is_ascii_digit()) { + return false; + } + + // Third part should be microseconds (digits) + if micros.is_empty() || !micros.chars().all(|c| c.is_ascii_digit()) { + return false; + } + + true +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::test_support::{cleanup, temp_dir}; + use tokio::fs; + + async fn create_build_and_log(base_dir: &Path, log_dir: &Path, site: &str, timestamp: &str) { + let build_dir = base_dir.join("builds").join(site).join(timestamp); + let site_log_dir = log_dir.join(site); + let log_file = site_log_dir.join(format!("{timestamp}.log")); + + fs::create_dir_all(&build_dir).await.unwrap(); + fs::create_dir_all(&site_log_dir).await.unwrap(); + fs::write(&log_file, "test log content").await.unwrap(); + fs::write(build_dir.join("index.html"), "<html></html>") + .await + .unwrap(); + } + + #[tokio::test] + async fn cleanup_removes_old_builds_and_logs() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create 7 builds (keep 5, remove 2) + let timestamps = [ + "20260126-100000-000001", + "20260126-100000-000002", + "20260126-100000-000003", + "20260126-100000-000004", + "20260126-100000-000005", + "20260126-100000-000006", + "20260126-100000-000007", + ]; + + for ts in ×tamps { + create_build_and_log(&base_dir, &log_dir, site, ts).await; + } + + let result = cleanup_old_builds(&base_dir, &log_dir, site, 5).await; + assert!(result.is_ok(), "cleanup should succeed: {result:?}"); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 2, "should remove 2 builds"); + assert_eq!(result.logs_removed, 2, "should remove 2 logs"); + + // Verify oldest 2 are gone + let builds_dir = base_dir.join("builds").join(site); + assert!(!builds_dir.join("20260126-100000-000001").exists()); + assert!(!builds_dir.join("20260126-100000-000002").exists()); + + // Verify newest 5 remain + assert!(builds_dir.join("20260126-100000-000003").exists()); + assert!(builds_dir.join("20260126-100000-000007").exists()); + + // Verify log cleanup + let site_logs = log_dir.join(site); + assert!(!site_logs.join("20260126-100000-000001.log").exists()); + assert!(site_logs.join("20260126-100000-000003.log").exists()); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_with_fewer_builds_than_max() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create only 3 builds (max is 5) + for ts in &[ + "20260126-100000-000001", + "20260126-100000-000002", + "20260126-100000-000003", + ] { + create_build_and_log(&base_dir, &log_dir, site, ts).await; + } + + let result = cleanup_old_builds(&base_dir, &log_dir, site, 5).await; + assert!(result.is_ok()); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 0, "should not remove any builds"); + assert_eq!(result.logs_removed, 0, "should not remove any logs"); + + // Verify all builds remain + let builds_dir = base_dir.join("builds").join(site); + assert!(builds_dir.join("20260126-100000-000001").exists()); + assert!(builds_dir.join("20260126-100000-000002").exists()); + assert!(builds_dir.join("20260126-100000-000003").exists()); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_preserves_current_symlink() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create builds + create_build_and_log(&base_dir, &log_dir, site, "20260126-100000-000001").await; + create_build_and_log(&base_dir, &log_dir, site, "20260126-100000-000002").await; + create_build_and_log(&base_dir, &log_dir, site, "20260126-100000-000003").await; + + // Create 'current' symlink + let builds_dir = base_dir.join("builds").join(site); + let current = builds_dir.join("current"); + let target = builds_dir.join("20260126-100000-000003"); + tokio::fs::symlink(&target, ¤t).await.unwrap(); + + let result = cleanup_old_builds(&base_dir, &log_dir, site, 2).await; + assert!(result.is_ok()); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 1, "should remove 1 build"); + + // Verify symlink still exists and points correctly + assert!(current.exists(), "current symlink should exist"); + let link_target = fs::read_link(¤t).await.unwrap(); + assert_eq!(link_target, target); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_handles_missing_logs_gracefully() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create builds but only some logs + let builds_dir = base_dir.join("builds").join(site); + fs::create_dir_all(builds_dir.join("20260126-100000-000001")) + .await + .unwrap(); + fs::create_dir_all(builds_dir.join("20260126-100000-000002")) + .await + .unwrap(); + fs::create_dir_all(builds_dir.join("20260126-100000-000003")) + .await + .unwrap(); + + // Only create log for one build + let site_logs = log_dir.join(site); + fs::create_dir_all(&site_logs).await.unwrap(); + fs::write(site_logs.join("20260126-100000-000001.log"), "log") + .await + .unwrap(); + + let result = cleanup_old_builds(&base_dir, &log_dir, site, 2).await; + assert!(result.is_ok(), "should succeed even with missing logs"); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 1, "should remove 1 build"); + assert_eq!(result.logs_removed, 1, "should remove 1 log"); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_with_max_zero_keeps_all() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create builds + for ts in &[ + "20260126-100000-000001", + "20260126-100000-000002", + "20260126-100000-000003", + ] { + create_build_and_log(&base_dir, &log_dir, site, ts).await; + } + + let result = cleanup_old_builds(&base_dir, &log_dir, site, 0).await; + assert!(result.is_ok()); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 0, "max 0 should keep all"); + assert_eq!(result.logs_removed, 0); + + // Verify all builds remain + let builds_dir = base_dir.join("builds").join(site); + assert!(builds_dir.join("20260126-100000-000001").exists()); + assert!(builds_dir.join("20260126-100000-000002").exists()); + assert!(builds_dir.join("20260126-100000-000003").exists()); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_nonexistent_builds_dir() { + let base_dir = temp_dir("cleanup-test").await; + let site = "nonexistent-site"; + + let log_dir = base_dir.join("logs"); + let result = cleanup_old_builds(&base_dir, &log_dir, site, 5).await; + assert!(result.is_ok(), "should succeed for nonexistent dir"); + let result = result.unwrap(); + + assert_eq!(result.builds_removed, 0); + assert_eq!(result.logs_removed, 0); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn cleanup_removes_orphaned_tmp_files() { + let base_dir = temp_dir("cleanup-test").await; + let log_dir = base_dir.join("logs"); + let site = "test-site"; + + // Create a build so cleanup runs + create_build_and_log(&base_dir, &log_dir, site, "20260126-100000-000001").await; + + // Create orphaned temp files in site log dir + let site_log_dir = log_dir.join(site); + fs::write( + site_log_dir.join("20260126-100000-000001-stdout.tmp"), + "orphan", + ) + .await + .unwrap(); + fs::write( + site_log_dir.join("20260126-100000-000001-stderr.tmp"), + "orphan", + ) + .await + .unwrap(); + fs::write(site_log_dir.join("random.tmp"), "orphan") + .await + .unwrap(); + + assert!( + site_log_dir + .join("20260126-100000-000001-stdout.tmp") + .exists() + ); + + // Run cleanup (max_to_keep=5 means no builds removed, but tmp files should go) + let result = cleanup_old_builds(&base_dir, &log_dir, site, 5).await; + assert!(result.is_ok()); + + // Temp files should be gone + assert!( + !site_log_dir + .join("20260126-100000-000001-stdout.tmp") + .exists() + ); + assert!( + !site_log_dir + .join("20260126-100000-000001-stderr.tmp") + .exists() + ); + assert!(!site_log_dir.join("random.tmp").exists()); + + // Log file should still exist + assert!(site_log_dir.join("20260126-100000-000001.log").exists()); + + cleanup(&base_dir).await; + } +} |
