prune: garbage-collect pkh test run directories

Test artifacts land under /var/tmp/pkh/tests (<source>-<series>-<arch>-
<timestamp>/), one directory per run: they are disposable results, not
state worth keeping. Prune removes them on the same 7-day retention as
build sessions, and with --all; the root is overridable through
PKH_TESTS_DIR like the sessions root.
This commit is contained in:
2026-09-27 21:08:22 +02:00
parent 2a9287d942
commit 15cc19007f
2 changed files with 108 additions and 9 deletions
+96 -7
View File
@@ -99,6 +99,8 @@ enum Artifact {
LogFile(PathBuf),
/// A resumable build session root (chroot + journal).
Session(PathBuf),
/// A `pkh test` run directory (report artifacts).
TestRun(PathBuf),
}
impl Artifact {
@@ -109,7 +111,8 @@ impl Artifact {
| Artifact::LockFile(p)
| Artifact::Tarball(p)
| Artifact::LogFile(p)
| Artifact::Session(p) => p,
| Artifact::Session(p)
| Artifact::TestRun(p) => p,
}
}
@@ -121,6 +124,7 @@ impl Artifact {
Artifact::Tarball(_) => "cached chroot tarball",
Artifact::LogFile(_) => "build log",
Artifact::Session(_) => "resumable build session",
Artifact::TestRun(_) => "pkh test run",
}
}
}
@@ -142,6 +146,31 @@ fn discover_sessions(sessions_dir: &Path, all: bool) -> Vec<Artifact> {
.collect()
}
/// Discover the `pkh test` run directories prune may remove under
/// `tests_dir`: with `--all`, every run; otherwise only those untouched
/// for longer than the sessions retention window.
fn discover_test_runs(tests_dir: &Path, all: bool) -> Vec<Artifact> {
let Ok(entries) = fs::read_dir(tests_dir) else {
return Vec::new();
};
entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter(|path| {
all || fs::metadata(path)
.and_then(|meta| meta.modified())
.is_ok_and(|mtime| {
chrono::Utc::now()
.signed_duration_since(chrono::DateTime::<chrono::Utc>::from(mtime))
.to_std()
.is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION)
})
})
.map(Artifact::TestRun)
.collect()
}
/// Whether a session root is prunable under the given policy: everything
/// with `--all`, otherwise stale (untouched beyond the retention window)
/// or corrupt (no readable manifest) sessions. Live sessions record a
@@ -481,24 +510,28 @@ pub fn prune_in(
cache_dir: Option<&Path>,
options: PruneOptions,
) -> Result<PruneReport, Box<dyn std::error::Error>> {
prune_in_roots(temp_dir, cache_dir, None, options)
prune_in_roots(temp_dir, cache_dir, None, None, options)
}
/// Execute the prune operation against explicit roots.
///
/// `sessions_dir` opts in to build-session pruning (the production
/// [`prune()`] passes the real sessions root; tests pass a controlled
/// directory or `None`).
/// `sessions_dir` opts in to build-session pruning and `tests_dir` to
/// test-run pruning (the production [`prune()`] passes the real roots;
/// tests pass controlled directories or `None`).
pub fn prune_in_roots(
temp_dir: &Path,
cache_dir: Option<&Path>,
sessions_dir: Option<&Path>,
tests_dir: Option<&Path>,
options: PruneOptions,
) -> Result<PruneReport, Box<dyn std::error::Error>> {
let mut artifacts = discover_artifacts(temp_dir, cache_dir);
if let Some(sessions_dir) = sessions_dir {
artifacts.extend(discover_sessions(sessions_dir, options.all));
}
if let Some(tests_dir) = tests_dir {
artifacts.extend(discover_test_runs(tests_dir, options.all));
}
let mut report = PruneReport {
dry_run: options.dry_run,
@@ -565,6 +598,7 @@ pub fn prune(options: PruneOptions) -> Result<PruneReport, Box<dyn std::error::E
&temp_dir,
cache.as_deref(),
Some(&crate::deb::session::sessions_root()),
Some(&crate::test::runs_root()),
options,
)
}
@@ -765,6 +799,7 @@ none /tmp/other proc rw 0 0
Artifact::Tarball(_) => tarballs += 1,
Artifact::LogFile(_) => logs += 1,
Artifact::Session(_) => {}
Artifact::TestRun(_) => {}
}
}
assert_eq!(temp_dirs, 2);
@@ -1192,8 +1227,14 @@ none /tmp/other proc rw 0 0
fs::create_dir_all(&corrupt).unwrap();
fs::write(corrupt.join("session.json"), "not json").unwrap();
let report =
prune_in_roots(temp.path(), None, Some(&sessions), PruneOptions::default()).unwrap();
let report = prune_in_roots(
temp.path(),
None,
Some(&sessions),
None,
PruneOptions::default(),
)
.unwrap();
assert!(report.removed.contains(&stale));
assert!(report.removed.contains(&corrupt));
assert!(fresh.exists(), "a live session is not pruned by default");
@@ -1205,6 +1246,54 @@ none /tmp/other proc rw 0 0
temp.path(),
None,
Some(&sessions),
None,
PruneOptions {
all: true,
..Default::default()
},
)
.unwrap();
assert!(report.removed.contains(&fresh));
assert!(!fresh.exists());
}
/// `pkh test` run directories follow the retention window: stale
/// runs go, fresh runs stay, `--all` takes everything.
#[test]
fn prunes_test_runs_by_retention() {
let temp = tempdir().unwrap();
let tests = temp.path().join("tests");
let stale = tests.join("pkg-noble-amd64-20260101T000000");
let fresh = tests.join("pkg-noble-amd64-20991231T235959");
fs::create_dir_all(&stale).unwrap();
fs::create_dir_all(&fresh).unwrap();
// Directories open read-only (write-open is a file-only mode):
// futimens still applies through the read handle.
let dir = fs::File::open(&stale).unwrap();
dir.set_times(
std::fs::FileTimes::new()
.set_modified(SystemTime::now() - std::time::Duration::from_secs(8 * 24 * 60 * 60)),
)
.unwrap();
let report = prune_in_roots(
temp.path(),
None,
None,
Some(&tests),
PruneOptions::default(),
)
.unwrap();
assert!(report.removed.contains(&stale));
assert!(!stale.exists());
assert!(fresh.exists(), "a fresh test run is not pruned");
// --all removes every run.
let report = prune_in_roots(
temp.path(),
None,
None,
Some(&tests),
PruneOptions {
all: true,
..Default::default()
+12 -2
View File
@@ -530,12 +530,22 @@ fn drop_shell(
/// `/var/tmp/pkh/tests/<source>-<series>-<arch>-<timestamp>/`.
fn create_run_dir(package: &str, series: &str, arch: &str) -> std::io::Result<PathBuf> {
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S");
let dir =
PathBuf::from("/var/tmp/pkh/tests").join(format!("{package}-{series}-{arch}-{timestamp}"));
let dir = runs_root().join(format!("{package}-{series}-{arch}-{timestamp}"));
fs::create_dir_all(&dir)?;
Ok(dir)
}
/// The test-run artifacts root: `$PKH_TESTS_DIR`, defaulting to
/// `/var/tmp/pkh/tests`.
pub fn runs_root() -> PathBuf {
if let Ok(dir) = std::env::var("PKH_TESTS_DIR")
&& !dir.is_empty()
{
return PathBuf::from(dir);
}
PathBuf::from("/var/tmp/pkh/tests")
}
/// Render the tests declared in the tree's `debian/tests/control`, one
/// block per test (`pkh test --list-tests`).
pub fn list_tests(tree: &Path) -> Result<String, Box<dyn std::error::Error>> {