use crate::storage::{
directory_bytes, is_inside, marker_active, read_marker, remove_app_root, remove_children,
safe_dir, safe_key, try_lock, RunKind, RunMarker, RunOutcome, RUN_MARKER,
};
use crate::util::{err, now_ms, Result};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
const LOCK_WAIT: Duration = Duration::from_secs(10);
#[derive(Debug, Default, Clone)]
pub struct RunsRetentionReport {
pub removed: Vec<(PathBuf, u64, String)>,
pub kept: Vec<(PathBuf, String)>,
pub removed_bytes: u64,
pub dry_run: bool,
}
impl RunsRetentionReport {
pub fn summary(&self) -> String {
format!(
"{} {} run dir(s) ({} bytes), kept {}",
if self.dry_run {
"would remove"
} else {
"removed"
},
self.removed.len(),
self.removed_bytes,
self.kept.len()
)
}
}
pub fn managed_root_of_run(run_root: &Path) -> Option<PathBuf> {
let app_dir = run_root.parent()?;
let runs = app_dir.parent()?;
if runs.file_name()? != "runs" {
return None;
}
Some(runs.parent()?.to_path_buf())
}
pub fn prune_runs(
managed_root: &Path,
app: Option<&str>,
dry_run: bool,
) -> Result<RunsRetentionReport> {
let mut report = RunsRetentionReport {
dry_run,
..Default::default()
};
let app = app.map(|a| safe_key(a, "app")).transpose()?;
let Ok(root) = fs::canonicalize(managed_root) else {
return Ok(report);
};
let runs = root.join("runs");
match fs::symlink_metadata(&runs) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(report),
Err(e) => return Err(e.into()),
Ok(m) if m.file_type().is_symlink() || !m.is_dir() => {
return err(format!(
"refusing QA runs root that is not a plain directory: {}",
runs.display()
));
}
Ok(_) => {}
}
if fs::canonicalize(&runs)? != runs {
return err(format!(
"refusing QA runs root that resolves outside the managed root: {}",
runs.display()
));
}
let apps: Vec<String> = match app {
Some(a) => vec![a],
None => {
let mut found = vec![];
for entry in fs::read_dir(&runs)?.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let Ok(ft) = entry.file_type() else { continue };
if name.starts_with('.') || (!ft.is_dir() && !ft.is_symlink()) {
continue;
}
if safe_key(&name, "app").is_err() {
report
.kept
.push((entry.path(), "not a valid app directory name".into()));
continue;
}
found.push(name);
}
found.sort();
found
}
};
for name in apps {
prune_app(&runs, &name, dry_run, &mut report)?;
}
Ok(report)
}
pub fn prune_runs_after_job(managed_root: &Path, app: &str) -> RunsRetentionReport {
match prune_runs(managed_root, Some(app), false) {
Ok(r) => r,
Err(e) => RunsRetentionReport {
kept: vec![(
managed_root.join("runs").join(app),
format!("retention pass failed: {e}"),
)],
..Default::default()
},
}
}
struct Candidate {
dir: PathBuf,
marker: RunMarker,
}
fn prune_app(
runs: &Path,
app: &str,
dry_run: bool,
report: &mut RunsRetentionReport,
) -> Result<()> {
let app_dir = runs.join(app);
match fs::symlink_metadata(&app_dir) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e.into()),
Ok(m) if m.file_type().is_symlink() || !m.is_dir() => {
report
.kept
.push((app_dir, "app directory is a link or not a directory".into()));
return Ok(());
}
Ok(_) => {}
}
if fs::canonicalize(&app_dir).ok().as_deref() != Some(app_dir.as_path()) {
report
.kept
.push((app_dir, "app directory does not resolve to itself".into()));
return Ok(());
}
let _guard = if dry_run {
None
} else {
match try_lock(&app_dir, LOCK_WAIT)? {
Some(g) => Some(g),
None => {
report.kept.push((
app_dir,
"another retention pass holds the lock; skipped".into(),
));
return Ok(());
}
}
};
let now = now_ms();
let mut passing: BTreeMap<RunKind, Vec<Candidate>> = BTreeMap::new();
let mut doomed: Vec<(Candidate, String)> = vec![];
for entry in fs::read_dir(&app_dir)?.flatten() {
let dir = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let Ok(ft) = entry.file_type() else { continue };
if ft.is_symlink() {
report
.kept
.push((dir, "link skipped, never followed".into()));
continue;
}
if !ft.is_dir() || name.starts_with('.') {
continue;
}
let marker_path = dir.join(RUN_MARKER);
let marker_is_file = fs::symlink_metadata(&marker_path)
.map(|m| m.is_file())
.unwrap_or(false);
let Some(marker) = marker_is_file.then(|| read_marker(&marker_path)).flatten() else {
report
.kept
.push((dir, format!("no valid {RUN_MARKER}; not ours to remove")));
continue;
};
if marker.app != app || marker.run_id != name {
report
.kept
.push((dir, "run marker names another app or run id".into()));
continue;
}
if marker_active(&marker, now) {
report.kept.push((dir, "active run".into()));
continue;
}
let outcome = marker.outcome;
match outcome {
None => report
.kept
.push((dir, "no recorded outcome (running or older marker)".into())),
Some(RunOutcome::Failed) => report.kept.push((dir, "failed run".into())),
Some(RunOutcome::Skipped) => {
doomed.push((
Candidate { dir, marker },
"run executed nothing (all scenarios skipped)".into(),
));
}
Some(RunOutcome::Passed) => passing
.entry(marker.kind.unwrap_or(RunKind::Functional))
.or_default()
.push(Candidate { dir, marker }),
}
}
for (kind, mut list) in passing {
list.sort_by(|a, b| {
let key = |c: &Candidate| c.marker.completed_at.unwrap_or(c.marker.created_at);
key(b)
.cmp(&key(a))
.then_with(|| b.marker.run_id.cmp(&a.marker.run_id))
});
let mut it = list.into_iter();
if let Some(newest) = it.next() {
report
.kept
.push((newest.dir, format!("newest passing {kind} run")));
}
for old in it {
doomed.push((old, format!("older passing {kind} run")));
}
}
doomed.sort_by(|a, b| a.0.dir.cmp(&b.0.dir));
for (c, why) in doomed {
let bytes = directory_bytes(&c.dir).unwrap_or(0);
if dry_run {
report.removed_bytes = report.removed_bytes.saturating_add(bytes);
report.removed.push((c.dir, bytes, why));
continue;
}
match remove_run_dir(runs, &app_dir, &c) {
Ok(()) => {
report.removed_bytes = report.removed_bytes.saturating_add(bytes);
report.removed.push((c.dir, bytes, why));
}
Err(e) => report.kept.push((c.dir, format!("not removed: {e}"))),
}
}
Ok(())
}
fn remove_run_dir(runs: &Path, app_dir: &Path, c: &Candidate) -> std::result::Result<(), String> {
let dir = &c.dir;
let real = fs::canonicalize(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
if !safe_dir(dir)
|| real != *dir
|| real.parent() != Some(app_dir)
|| app_dir.parent() != Some(runs)
|| !is_inside(runs, &real)
{
return Err(format!(
"{} is not a plain run directory inside the runs root",
dir.display()
));
}
let marker_path = real.join(RUN_MARKER);
let marker_ok = fs::symlink_metadata(&marker_path)
.map(|m| m.is_file())
.unwrap_or(false)
&& read_marker(&marker_path)
.is_some_and(|m| m.app == c.marker.app && m.run_id == c.marker.run_id);
if !marker_ok {
return Err("run marker changed or vanished".into());
}
remove_app_root(&real, &c.marker).map_err(|e| e.0)?;
remove_children(&real, Some(std::ffi::OsStr::new(RUN_MARKER)))?;
remove_children(&real, None)?;
fs::remove_dir(&real).map_err(|e| format!("{}: {e}", real.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage;
use crate::util::new_id;
fn temp_root(label: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("rkqa-retention-{label}-{}", new_id()));
fs::create_dir_all(&p).unwrap();
fs::canonicalize(p).unwrap()
}
fn run(
root: &Path,
app: &str,
id: &str,
kind: Option<RunKind>,
outcome: Option<RunOutcome>,
done_at: u128,
) -> PathBuf {
let dir = root.join("runs").join(app).join(id);
fs::create_dir_all(dir.join("evidence")).unwrap();
fs::write(dir.join("evidence").join("proof.txt"), id).unwrap();
let marker = RunMarker {
schema_version: 1,
app: app.into(),
run_id: id.into(),
created_at: done_at,
owner_pid: 0,
lease_until: 0,
completed_at: Some(done_at),
app_root: None,
kind,
outcome,
};
storage::write_marker(&dir, &marker).unwrap();
dir
}
fn exists(p: &Path) -> bool {
fs::symlink_metadata(p).is_ok()
}
#[test]
fn keeps_failed_and_the_newest_pass_per_kind_and_removes_the_rest() {
let root = temp_root("rule");
let f = RunKind::Functional;
let v = RunKind::Visual;
let pass = Some(RunOutcome::Passed);
let fail_old = run(
&root,
"app",
"fail-old",
Some(f),
Some(RunOutcome::Failed),
1,
);
let f1 = run(&root, "app", "f1", Some(f), pass, 10);
let f2 = run(&root, "app", "f2", Some(f), pass, 20);
let f3 = run(&root, "app", "f3", Some(f), pass, 30);
let v1 = run(&root, "app", "v1", Some(v), pass, 5);
let v2 = run(&root, "app", "v2", Some(v), pass, 6);
let fail_visual = run(
&root,
"app",
"fail-visual",
Some(v),
Some(RunOutcome::Failed),
2,
);
let running = run(&root, "app", "running", Some(f), None, 40);
let nothing = run(
&root,
"app",
"nothing",
Some(f),
Some(RunOutcome::Skipped),
50,
);
let legacy = run(&root, "app", "legacy", None, pass, 3);
let other = run(&root, "other", "o1", Some(f), pass, 1);
let other2 = run(&root, "other", "o2", Some(f), pass, 2);
let r = prune_runs(&root, Some("app"), false).unwrap();
for gone in [&f1, &f2, &v1, ¬hing, &legacy] {
assert!(!exists(gone), "should be removed: {}", gone.display());
}
for kept in [&fail_old, &f3, &v2, &fail_visual, &running] {
assert!(exists(kept), "should be kept: {}", kept.display());
}
assert_eq!(r.removed.len(), 5);
assert!(r.removed_bytes > 0);
assert!(exists(&other) && exists(&other2));
let r = prune_runs(&root, None, false).unwrap();
assert!(!exists(&other) && exists(&other2));
assert_eq!(r.removed.len(), 1);
assert!(prune_runs(&root, None, false).unwrap().removed.is_empty());
}
#[test]
fn dry_run_reports_and_removes_nothing() {
let root = temp_root("dry");
let pass = Some(RunOutcome::Passed);
let old = run(&root, "app", "old", Some(RunKind::Functional), pass, 1);
let new = run(&root, "app", "new", Some(RunKind::Functional), pass, 2);
let r = prune_runs(&root, Some("app"), true).unwrap();
assert!(r.dry_run);
assert_eq!(r.removed.len(), 1);
assert!(exists(&old) && exists(&new));
}
#[test]
fn only_directories_with_a_matching_marker_are_pruned() {
let root = temp_root("marker");
let pass = Some(RunOutcome::Passed);
let f = Some(RunKind::Functional);
let app_dir = root.join("runs").join("app");
let newest = run(&root, "app", "newest", f, pass, 100);
let bare = app_dir.join("not-a-run");
fs::create_dir_all(&bare).unwrap();
fs::write(bare.join("keep.txt"), "x").unwrap();
let broken = app_dir.join("broken");
fs::create_dir_all(&broken).unwrap();
fs::write(broken.join(RUN_MARKER), "{ not json").unwrap();
let moved = run(&root, "app", "moved", f, pass, 1);
let renamed = app_dir.join("renamed");
fs::rename(&moved, &renamed).unwrap();
let alien = run(&root, "alien", "a1", f, pass, 1);
let planted = app_dir.join("a1");
fs::create_dir_all(&planted).unwrap();
fs::copy(alien.join(RUN_MARKER), planted.join(RUN_MARKER)).unwrap();
fs::write(app_dir.join("latest.json"), "{}").unwrap();
let old = run(&root, "app", "old", f, pass, 2);
let r = prune_runs(&root, Some("app"), false).unwrap();
assert!(!exists(&old));
for kept in [&newest, &bare, &broken, &renamed, &planted, &alien] {
assert!(exists(kept), "must stay: {}", kept.display());
}
assert!(exists(&bare.join("keep.txt")));
assert!(exists(&app_dir.join("latest.json")));
assert_eq!(r.removed.len(), 1);
}
#[test]
fn an_active_run_is_kept_even_with_a_recorded_pass() {
let root = temp_root("active");
let pass = Some(RunOutcome::Passed);
let f = Some(RunKind::Functional);
let live = run(&root, "app", "live", f, pass, 1);
storage::update_marker(&live, |m| m.lease_until = now_ms() + 3_600_000).unwrap();
let newer = run(&root, "app", "newer", f, pass, 2);
let r = prune_runs(&root, Some("app"), false).unwrap();
assert!(r.removed.is_empty());
assert!(exists(&live) && exists(&newer));
}
#[test]
fn unsafe_names_and_missing_roots_never_reach_the_disk() {
let root = temp_root("names");
assert!(prune_runs(&root, Some("../escape"), false).is_err());
assert!(prune_runs(&root, Some(""), false).is_err());
assert!(prune_runs(&root.join("absent"), None, false)
.unwrap()
.removed
.is_empty());
assert!(prune_runs(&root, None, false).unwrap().removed.is_empty());
}
#[test]
fn a_link_never_leads_a_pass_outside_the_runs_root() {
let root = temp_root("links");
let outside = temp_root("outside");
let pass = Some(RunOutcome::Passed);
let f = Some(RunKind::Functional);
let victim_old = run(&outside, "app", "old", f, pass, 1);
let victim_new = run(&outside, "app", "new", f, pass, 2);
if !try_symlink_dir(&outside.join("runs"), &root.join("runs")) {
return; }
let refused = prune_runs(&root, Some("app"), false).unwrap_err();
assert!(refused.0.contains("runs root"), "{refused}");
assert!(prune_runs(&root, None, false).is_err());
assert!(exists(&victim_old) && exists(&victim_new));
let root = temp_root("links2");
fs::create_dir_all(root.join("runs")).unwrap();
assert!(try_symlink_dir(
&outside.join("runs").join("app"),
&root.join("runs").join("app")
));
let r = prune_runs(&root, Some("app"), false).unwrap();
assert!(r.removed.is_empty());
assert!(r.kept.iter().any(|(_, why)| why.contains("link")));
assert!(prune_runs(&root, None, false).unwrap().removed.is_empty());
assert!(exists(&victim_old) && exists(&victim_new));
let root = temp_root("links3");
let newest = run(&root, "app", "newest", f, pass, 100);
assert!(try_symlink_dir(
&victim_old,
&root.join("runs").join("app").join("linked")
));
let doomed = run(&root, "app", "doomed", f, pass, 1);
let precious = temp_root("precious");
fs::write(precious.join("keep.txt"), "x").unwrap();
assert!(try_symlink_dir(
&precious,
&doomed.join("evidence").join("escape")
));
let r = prune_runs(&root, Some("app"), false).unwrap();
assert!(exists(&newest));
assert!(!exists(&doomed));
assert!(
exists(&precious.join("keep.txt")),
"link target was followed"
);
assert!(exists(&victim_old) && exists(&victim_new));
assert!(r
.kept
.iter()
.any(|(p, why)| p.ends_with("linked") && why.contains("link")));
}
#[test]
fn after_job_pass_prunes_and_reports_without_failing() {
let root = temp_root("after");
let pass = Some(RunOutcome::Passed);
let f = Some(RunKind::Functional);
let old = run(&root, "app", "old", f, pass, 1);
let new = run(&root, "app", "new", f, pass, 2);
let r = prune_runs_after_job(&root, "app");
assert!(!exists(&old) && exists(&new));
assert_eq!(r.removed.len(), 1);
assert!(r.summary().starts_with("removed 1"));
let bad = prune_runs_after_job(&root, "../x");
assert!(bad.removed.is_empty() && !bad.kept.is_empty());
}
#[test]
fn managed_root_of_run_needs_the_runs_shape() {
let p = Path::new("/m/runs/app/id");
assert_eq!(managed_root_of_run(p), Some(PathBuf::from("/m")));
assert_eq!(managed_root_of_run(Path::new("/m/other/app/id")), None);
assert_eq!(managed_root_of_run(Path::new("id")), None);
}
fn try_symlink_dir(target: &Path, link: &Path) -> bool {
#[cfg(unix)]
let r = std::os::unix::fs::symlink(target, link);
#[cfg(windows)]
let r = std::os::windows::fs::symlink_dir(target, link);
r.is_ok()
}
}