runner_manager_platform/
log_retention.rs1use std::fs;
9use std::io;
10use std::path::Path;
11use std::time::{Duration, SystemTime};
12
13pub const MAX_ATTEMPT_LOGS: usize = 1_000;
15pub const ATTEMPT_LOG_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
17
18#[derive(Debug, Default, PartialEq, Eq)]
20pub struct PruneReport {
21 pub removed: usize,
23 pub failed: usize,
25}
26
27pub 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 if !uuid::Uuid::try_parse(id).is_ok_and(|uuid| uuid.to_string() == id) {
55 continue;
56 }
57 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}