Skip to main content

runner_manager_platform/
log_retention.rs

1//! Bounded retention for the redacted summaries written after runner attempts.
2//!
3//! These live outside the disposable workspace, so removing that workspace
4//! never removed its `<attempt UUID>.log`. Only that exact filename shape is
5//! eligible here; rotating service logs, other files, directories and links
6//! are left alone. No runtime or dependency-cache directory is walked.
7
8use std::fs;
9use std::io;
10use std::path::Path;
11use std::time::{Duration, SystemTime};
12
13/// Keep at most this many completed-attempt summaries.
14pub const MAX_ATTEMPT_LOGS: usize = 1_000;
15/// Even a quiet host need not keep summaries indefinitely.
16pub const ATTEMPT_LOG_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
17
18/// A failed removal is retried on the next maintenance pass.
19#[derive(Debug, Default, PartialEq, Eq)]
20pub struct PruneReport {
21    /// Summaries successfully unlinked.
22    pub removed: usize,
23    /// Summaries still present because their removal failed.
24    pub failed: usize,
25}
26
27/// Remove expired summaries and the oldest ones above the count limit.
28///
29/// Missing directories are normal before the first attempt. Other enumeration
30/// errors are returned rather than reporting an incomplete scan as success.
31pub fn prune_attempt_logs(directory: &Path, now: SystemTime) -> io::Result<PruneReport> {
32    prune(directory, now, MAX_ATTEMPT_LOGS, ATTEMPT_LOG_MAX_AGE)
33}
34
35fn prune(
36    directory: &Path,
37    now: SystemTime,
38    limit: usize,
39    max_age: Duration,
40) -> io::Result<PruneReport> {
41    let entries = match fs::read_dir(directory) {
42        Ok(entries) => entries,
43        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(PruneReport::default()),
44        Err(error) => return Err(error),
45    };
46    let mut logs = Vec::new();
47    for entry in entries {
48        let entry = entry?;
49        let name = entry.file_name();
50        let Some(id) = name.to_str().and_then(|name| name.strip_suffix(".log")) else {
51            continue;
52        };
53        // Only the canonical spelling produced by AttemptId's Display.
54        if !uuid::Uuid::try_parse(id).is_ok_and(|uuid| uuid.to_string() == id) {
55            continue;
56        }
57        // DirEntry::file_type does not follow symlinks/junctions.
58        if !entry.file_type()?.is_file() {
59            continue;
60        }
61        let modified = entry.metadata()?.modified()?;
62        logs.push((modified, entry.path()));
63    }
64    logs.sort();
65    let excess = logs.len().saturating_sub(limit);
66    let mut report = PruneReport::default();
67    for (index, (modified, path)) in logs.into_iter().enumerate() {
68        let expired = now.duration_since(modified).is_ok_and(|age| age >= max_age);
69        if index < excess || expired {
70            match fs::remove_file(path) {
71                Ok(()) => report.removed += 1,
72                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
73                Err(_) => report.failed += 1,
74            }
75        }
76    }
77    Ok(report)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use std::path::PathBuf;
84
85    fn summary(root: &Path, id: u128, modified: SystemTime) -> PathBuf {
86        let path = root.join(format!("{}.log", uuid::Uuid::from_u128(id)));
87        let file = fs::File::create(&path).unwrap();
88        file.set_modified(modified).unwrap();
89        path
90    }
91
92    #[test]
93    fn removes_old_summaries_and_bounds_recent_history_without_touching_other_files() {
94        let root = tempfile::tempdir().unwrap();
95        let now = SystemTime::now();
96        let expired = summary(root.path(), 1, now - ATTEMPT_LOG_MAX_AGE);
97        let excess = summary(root.path(), 2, now - Duration::from_secs(20));
98        let recent = summary(root.path(), 3, now - Duration::from_secs(10));
99        let newest = summary(root.path(), 4, now);
100        let service = root.path().join("runner-manager.service.log.2020-01-01");
101        let unrelated = root.path().join("customer.log");
102        fs::write(&service, "service diagnostics").unwrap();
103        fs::write(&unrelated, "not an attempt summary").unwrap();
104        let directory = root
105            .path()
106            .join(format!("{}.log", uuid::Uuid::from_u128(5)));
107        fs::create_dir(&directory).unwrap();
108
109        let report = prune(root.path(), now, 2, ATTEMPT_LOG_MAX_AGE).unwrap();
110        assert_eq!(
111            report,
112            PruneReport {
113                removed: 2,
114                failed: 0
115            }
116        );
117        assert!(!expired.exists());
118        assert!(!excess.exists());
119        for kept in [recent, newest, service, unrelated, directory] {
120            assert!(kept.exists(), "{} was removed", kept.display());
121        }
122    }
123
124    #[test]
125    fn expires_a_quiet_hosts_history_and_tolerates_clock_rollback_and_missing_directory() {
126        let root = tempfile::tempdir().unwrap();
127        let now = SystemTime::now();
128        let expired = summary(root.path(), 1, now - ATTEMPT_LOG_MAX_AGE);
129        let future = summary(root.path(), 2, now + Duration::from_secs(60));
130        let report = prune_attempt_logs(root.path(), now).unwrap();
131        assert_eq!(report.removed, 1);
132        assert!(!expired.exists());
133        assert!(future.exists());
134        assert_eq!(
135            prune_attempt_logs(&root.path().join("missing"), now).unwrap(),
136            PruneReport::default()
137        );
138    }
139
140    #[cfg(unix)]
141    #[test]
142    fn never_follows_a_summary_shaped_symlink() {
143        let root = tempfile::tempdir().unwrap();
144        let outside = tempfile::tempdir().unwrap();
145        let now = SystemTime::now();
146        let target = summary(outside.path(), 1, now - ATTEMPT_LOG_MAX_AGE);
147        let link = root
148            .path()
149            .join(format!("{}.log", uuid::Uuid::from_u128(2)));
150        std::os::unix::fs::symlink(&target, &link).unwrap();
151        assert_eq!(
152            prune_attempt_logs(root.path(), now).unwrap(),
153            PruneReport::default()
154        );
155        assert!(link.symlink_metadata().is_ok());
156        assert!(target.exists());
157    }
158
159    #[cfg(windows)]
160    #[test]
161    fn an_unremovable_summary_does_not_block_other_cleanup_and_is_retried() {
162        use std::os::windows::fs::OpenOptionsExt as _;
163
164        let root = tempfile::tempdir().unwrap();
165        let now = SystemTime::now();
166        let locked = summary(root.path(), 1, now - ATTEMPT_LOG_MAX_AGE);
167        let removable = summary(root.path(), 2, now - ATTEMPT_LOG_MAX_AGE);
168        let handle = fs::OpenOptions::new()
169            .read(true)
170            .share_mode(0)
171            .open(&locked)
172            .unwrap();
173        let report = prune_attempt_logs(root.path(), now).unwrap();
174        assert_eq!(
175            report,
176            PruneReport {
177                removed: 1,
178                failed: 1
179            }
180        );
181        assert!(locked.exists());
182        assert!(!removable.exists());
183        drop(handle);
184        assert_eq!(prune_attempt_logs(root.path(), now).unwrap().removed, 1);
185        assert!(!locked.exists());
186    }
187}