| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
use crate::config::Config; |
| 11 |
use crate::domain::Version; |
| 12 |
use std::collections::HashSet; |
| 13 |
use std::path::{Path, PathBuf}; |
| 14 |
|
| 15 |
|
| 16 |
|
| 17 |
pub const KEEP_VERSIONS_PER_APP: usize = 5; |
| 18 |
|
| 19 |
|
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
|
| 31 |
pub type ProtectedVersions = HashSet<(String, String)>; |
| 32 |
|
| 33 |
|
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
pub fn prune_once(cfg: &Config, protected: &ProtectedVersions) { |
| 38 |
prune_root(&cfg.dist_root, "dist", protected); |
| 39 |
prune_root(&cfg.logs_root, "logs", protected); |
| 40 |
} |
| 41 |
|
| 42 |
fn prune_root(root: &Path, label: &str, protected: &ProtectedVersions) { |
| 43 |
let Ok(apps) = std::fs::read_dir(root) else { |
| 44 |
return; |
| 45 |
}; |
| 46 |
for app in apps.filter_map(std::result::Result::ok) { |
| 47 |
let app_dir = app.path(); |
| 48 |
if app_dir.is_dir() { |
| 49 |
let app_name = app.file_name().to_string_lossy().into_owned(); |
| 50 |
prune_app_dir(&app_dir, &app_name, label, protected); |
| 51 |
} |
| 52 |
} |
| 53 |
} |
| 54 |
|
| 55 |
fn prune_app_dir(app_dir: &Path, app_name: &str, label: &str, protected: &ProtectedVersions) { |
| 56 |
let Ok(entries) = std::fs::read_dir(app_dir) else { |
| 57 |
return; |
| 58 |
}; |
| 59 |
|
| 60 |
|
| 61 |
let mut versions: Vec<(Version, String, PathBuf)> = entries |
| 62 |
.filter_map(std::result::Result::ok) |
| 63 |
.filter(|e| e.path().is_dir()) |
| 64 |
.filter_map(|e| { |
| 65 |
let name = e.file_name().to_string_lossy().into_owned(); |
| 66 |
let v = Version::parse(&name).ok()?; |
| 67 |
Some((v, name, e.path())) |
| 68 |
}) |
| 69 |
.collect(); |
| 70 |
if versions.len() <= KEEP_VERSIONS_PER_APP { |
| 71 |
return; |
| 72 |
} |
| 73 |
versions.sort_by(|a, b| b.0.cmp(&a.0)); |
| 74 |
for (ver, name, path) in versions.into_iter().skip(KEEP_VERSIONS_PER_APP) { |
| 75 |
|
| 76 |
|
| 77 |
if protected.contains(&(app_name.to_string(), name)) { |
| 78 |
tracing::debug!(%ver, app = app_name, "retention: skipping protected {label} version"); |
| 79 |
continue; |
| 80 |
} |
| 81 |
match std::fs::remove_dir_all(&path) { |
| 82 |
Ok(()) => { |
| 83 |
tracing::info!(%ver, dir = %path.display(), "retention: pruned old {label} version"); |
| 84 |
} |
| 85 |
Err(e) => { |
| 86 |
tracing::warn!(error = %e, dir = %path.display(), "retention: could not prune {label} dir"); |
| 87 |
} |
| 88 |
} |
| 89 |
} |
| 90 |
} |
| 91 |
|
| 92 |
#[cfg(test)] |
| 93 |
mod tests { |
| 94 |
use super::*; |
| 95 |
|
| 96 |
fn touch_version(root: &Path, app: &str, ver: &str) { |
| 97 |
let d = root.join(app).join(ver); |
| 98 |
std::fs::create_dir_all(&d).unwrap(); |
| 99 |
std::fs::write(d.join("artifact.bin"), b"x").unwrap(); |
| 100 |
} |
| 101 |
|
| 102 |
#[test] |
| 103 |
fn keeps_newest_n_and_prunes_older_by_semver() { |
| 104 |
let tmp = tempfile::tempdir().unwrap(); |
| 105 |
let root = tmp.path().join("dist"); |
| 106 |
|
| 107 |
for v in [ |
| 108 |
"0.4.0", "0.4.1", "0.4.2", "0.4.9", "0.4.10", "0.5.0", "0.5.1", |
| 109 |
] { |
| 110 |
touch_version(&root, "demo", v); |
| 111 |
} |
| 112 |
|
| 113 |
std::fs::create_dir_all(root.join("demo").join("scratch")).unwrap(); |
| 114 |
|
| 115 |
prune_root(&root, "dist", &ProtectedVersions::new()); |
| 116 |
|
| 117 |
let mut kept: Vec<String> = std::fs::read_dir(root.join("demo")) |
| 118 |
.unwrap() |
| 119 |
.filter_map(std::result::Result::ok) |
| 120 |
.map(|e| e.file_name().to_string_lossy().into_owned()) |
| 121 |
.collect(); |
| 122 |
kept.sort(); |
| 123 |
|
| 124 |
assert_eq!( |
| 125 |
kept, |
| 126 |
vec!["0.4.10", "0.4.2", "0.4.9", "0.5.0", "0.5.1", "scratch"] |
| 127 |
); |
| 128 |
} |
| 129 |
|
| 130 |
#[test] |
| 131 |
fn no_op_when_under_limit() { |
| 132 |
let tmp = tempfile::tempdir().unwrap(); |
| 133 |
let root = tmp.path().join("logs"); |
| 134 |
for v in ["0.1.0", "0.2.0"] { |
| 135 |
touch_version(&root, "demo", v); |
| 136 |
} |
| 137 |
prune_root(&root, "logs", &ProtectedVersions::new()); |
| 138 |
assert_eq!(std::fs::read_dir(root.join("demo")).unwrap().count(), 2); |
| 139 |
} |
| 140 |
|
| 141 |
#[test] |
| 142 |
fn never_prunes_a_protected_version() { |
| 143 |
let tmp = tempfile::tempdir().unwrap(); |
| 144 |
let root = tmp.path().join("dist"); |
| 145 |
|
| 146 |
for v in [ |
| 147 |
"0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0", |
| 148 |
] { |
| 149 |
touch_version(&root, "demo", v); |
| 150 |
} |
| 151 |
|
| 152 |
|
| 153 |
let protected: ProtectedVersions = [("demo".to_string(), "0.1.0".to_string())] |
| 154 |
.into_iter() |
| 155 |
.collect(); |
| 156 |
prune_root(&root, "dist", &protected); |
| 157 |
assert!( |
| 158 |
root.join("demo").join("0.1.0").exists(), |
| 159 |
"protected version must survive" |
| 160 |
); |
| 161 |
|
| 162 |
assert!(!root.join("demo").join("0.2.0").exists()); |
| 163 |
assert!( |
| 164 |
root.join("demo").join("0.3.0").exists(), |
| 165 |
"newest 5 are kept" |
| 166 |
); |
| 167 |
} |
| 168 |
|
| 169 |
#[test] |
| 170 |
fn protects_a_released_version_far_outside_the_window() { |
| 171 |
let tmp = tempfile::tempdir().unwrap(); |
| 172 |
let root = tmp.path().join("dist"); |
| 173 |
|
| 174 |
|
| 175 |
for v in [ |
| 176 |
"0.5.0", "0.5.1", "0.5.2", "0.5.3", "0.5.4", "0.5.5", "0.5.6", |
| 177 |
] { |
| 178 |
touch_version(&root, "demo", v); |
| 179 |
} |
| 180 |
|
| 181 |
|
| 182 |
let protected: ProtectedVersions = [("demo".to_string(), "0.5.0".to_string())] |
| 183 |
.into_iter() |
| 184 |
.collect(); |
| 185 |
prune_root(&root, "dist", &protected); |
| 186 |
assert!( |
| 187 |
root.join("demo").join("0.5.0").exists(), |
| 188 |
"a released version must survive even far outside the keep window" |
| 189 |
); |
| 190 |
|
| 191 |
assert!(!root.join("demo").join("0.5.1").exists()); |
| 192 |
} |
| 193 |
} |
| 194 |
|