From 15cc19007fed32606db69db1ab39ce6ffb7213d3 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sun, 27 Sep 2026 21:08:22 +0200 Subject: [PATCH] prune: garbage-collect pkh test run directories Test artifacts land under /var/tmp/pkh/tests (--- /), 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. --- src/prune.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++---- src/test/mod.rs | 14 ++++++- 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/src/prune.rs b/src/prune.rs index 641824b..3228a4b 100644 --- a/src/prune.rs +++ b/src/prune.rs @@ -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 { .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 { + 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::::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> { - 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> { 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 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() diff --git a/src/test/mod.rs b/src/test/mod.rs index fa69db0..261342c 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -530,12 +530,22 @@ fn drop_shell( /// `/var/tmp/pkh/tests/---/`. fn create_run_dir(package: &str, series: &str, arch: &str) -> std::io::Result { 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> {