1use std::collections::BTreeSet;
12use std::fmt::Write as _;
13
14use crate::api;
15use crate::config;
16
17pub const IMAGE: &str = "runnerd-record";
19
20pub const LABEL: &str = "runnerd.record";
22
23pub const SHELL_LABEL: &str = "runnerd.shell";
25
26pub fn image(job: u64) -> String {
28 format!("{IMAGE}:{job}")
29}
30
31const FAILED: &str = "completed with result: Failed";
33
34pub fn failed(runner_log: &str) -> bool {
36 runner_log.lines().any(|line| line.ends_with(FAILED))
37}
38
39const HOME: &str = "/home/runner/";
43
44const SECRET_DIRS: &[&str] = &[
48 "/run/runnerd",
49 "/home/runner/_work/_temp",
50 "/tmp",
51 "/var/tmp",
52];
53
54const HARMLESS: &[&str] = &[
59 "/home/runner/.rustup",
60 "/home/runner/.cargo/bin",
61 "/home/runner/.cargo/registry",
62 "/home/runner/.cargo/git",
63];
64
65fn harmless(path: &str, changed: &[&str]) -> bool {
68 let below = |dir: &str, path: &str| {
69 path.strip_prefix(dir)
70 .is_some_and(|rest| rest.starts_with('/'))
71 };
72 let on_the_way = HARMLESS.iter().any(|dir| *dir == path || below(path, dir));
73 HARMLESS.iter().any(|dir| below(dir, path))
74 || on_the_way && changed.iter().any(|other| below(path, other))
75}
76
77pub fn secrets_left<'path>(changed: impl IntoIterator<Item = &'path str>) -> Vec<String> {
82 let changed: Vec<&str> = changed.into_iter().collect();
83 changed
84 .iter()
85 .copied()
86 .filter(|path| !harmless(path, &changed))
87 .filter(|path| {
88 path.strip_prefix(HOME)
89 .is_some_and(|rest| rest.starts_with('.'))
90 || SECRET_DIRS.iter().any(|dir| {
91 path.strip_prefix(dir)
92 .is_some_and(|rest| rest.starts_with('/'))
93 })
94 })
95 .map(str::to_owned)
96 .collect()
97}
98
99pub fn listed(paths: &[String]) -> String {
101 const SHOWN: usize = 5;
102 let mut text = paths
103 .iter()
104 .take(SHOWN)
105 .map(String::as_str)
106 .collect::<Vec<_>>()
107 .join(", ");
108 if paths.len() > SHOWN {
109 write!(text, " and {} more", paths.len() - SHOWN).ok();
110 }
111 text
112}
113
114pub fn make_room(
117 records: &[api::Record],
118 open: &BTreeSet<u64>,
119 limits: &config::Records,
120 incoming: u64,
121 free: Option<u64>,
122 now: u64,
123) -> Result<Vec<(u64, api::RecordRemoval)>, String> {
124 if incoming > limits.max_total.0 {
125 return Err(format!(
126 "it would take {}, more than records.max_total ({})",
127 config::ByteSize(incoming),
128 limits.max_total
129 ));
130 }
131 let mut removed = expired(records, open, limits, now);
132 let mut kept: Vec<&api::Record> = records
133 .iter()
134 .filter(|record| !removed.iter().any(|(job, _)| *job == record.job))
135 .collect();
136 kept.sort_by_key(|record| (record.created_at, record.job));
137 let mut removable = kept
138 .clone()
139 .into_iter()
140 .filter(|record| !open.contains(&record.job));
141 let max_count = usize::try_from(limits.max_count).unwrap_or(usize::MAX);
142 let mut count = kept.len();
143 let mut total: u64 = kept.iter().map(|record| record.bytes).sum();
144 while count >= max_count || total + incoming > limits.max_total.0 {
145 let why = if count >= max_count {
146 api::RecordRemoval::Count
147 } else {
148 api::RecordRemoval::Size
149 };
150 let Some(oldest) = removable.next() else {
151 return Err("the records kept within the limits have shells open".to_owned());
152 };
153 removed.push((oldest.job, why));
154 count -= 1;
155 total -= oldest.bytes;
156 }
157 if let Some(free) = free {
158 let freed: u64 = records
159 .iter()
160 .filter(|record| removed.iter().any(|(job, _)| *job == record.job))
161 .map(|record| record.bytes)
162 .sum();
163 if (free + freed).saturating_sub(incoming) < limits.min_free.0 {
164 return Err(format!(
165 "Docker has {} free; a record of {} would leave less than records.min_free ({})",
166 config::ByteSize(free),
167 config::ByteSize(incoming),
168 limits.min_free
169 ));
170 }
171 }
172 Ok(removed)
173}
174
175pub fn expired(
177 records: &[api::Record],
178 open: &BTreeSet<u64>,
179 limits: &config::Records,
180 now: u64,
181) -> Vec<(u64, api::RecordRemoval)> {
182 records
183 .iter()
184 .filter(|record| {
185 !open.contains(&record.job)
186 && now.saturating_sub(record.created_at) > limits.max_age.as_secs()
187 })
188 .map(|record| (record.job, api::RecordRemoval::Age))
189 .collect()
190}
191
192pub fn workspace(repository: &str) -> String {
194 let name = repository.rsplit('/').next().unwrap_or(repository);
195 format!("/home/runner/_work/{name}/{name}")
196}
197
198pub fn banner(job: &api::Job, log: &[String], step: Option<u32>) -> String {
202 let summary = &job.summary;
203 let mut text = format!(
204 "runnerd: the record of job {} of {}",
205 summary.id, summary.repository
206 );
207 match &summary.workflow {
208 Some(workflow) => write!(text, ", {workflow} / {}", summary.name),
209 None => write!(text, ", {}", summary.name),
210 }
211 .ok();
212 text.push('\n');
213 let failed = job
214 .steps
215 .iter()
216 .find(|candidate| candidate.conclusion.as_deref() == Some("failure"));
217 let shown = match step {
218 Some(number) => job
219 .steps
220 .iter()
221 .find(|candidate| candidate.number == number),
222 None => failed,
223 };
224 let sections = sections(log);
225 match shown {
226 Some(shown) => {
227 let verb = if failed.is_some_and(|failed| failed.number == shown.number) {
228 "failed at"
229 } else {
230 "shows"
231 };
232 write!(text, "It {verb} step {}: {}", shown.number, shown.name).ok();
233 let section = section_of(job, §ions, shown);
234 if let Some(code) = section.and_then(|section| section.exit_code) {
235 write!(text, " (exit code {code})").ok();
236 }
237 text.push('\n');
238 match section {
239 Some(section) => {
240 text.push_str("Its log shows it ran:\n");
241 for line in §ion.lines {
242 writeln!(text, " {line}").ok();
243 }
244 }
245 None => text.push_str("Its log does not show what it ran.\n"),
246 }
247 }
248 None if step.is_some() => text.push_str("The job has no such step.\n"),
249 None => text.push_str("The job's steps do not say which one failed.\n"),
250 }
251 writeln!(
252 text,
253 "You are in {}, as the job left it. Not kept: the job's Docker containers and images; what it changed \
254 in ~/.cache, the tool cache and the Android SDK, which show the profile's snapshot; and what it added or \
255 changed among your home's dot-files and in /tmp (logins, and tools in ~/.local or ~/.nvm), except \
256 ~/.rustup and ~/.cargo's bin, registry and git. Exit to remove this container.",
257 workspace(&summary.repository)
258 )
259 .ok();
260 text.replace('\n', "\r\n")
261}
262
263#[derive(Debug, Default)]
265struct Section {
266 title: String,
268 lines: Vec<String>,
270 exit_code: Option<i64>,
272}
273
274fn sections(log: &[String]) -> Vec<Section> {
276 let mut sections: Vec<Section> = Vec::new();
277 let mut in_group = false;
278 for line in log {
279 let text = plain(line);
280 if let Some(title) = text.strip_prefix("##[group]Run ") {
281 sections.push(Section {
282 title: title.to_owned(),
283 ..Section::default()
284 });
285 in_group = true;
286 } else if text.starts_with("##[endgroup]") {
287 in_group = false;
288 } else if let Some(section) = sections.last_mut() {
289 if in_group {
290 section.lines.push(text);
291 } else if let Some(code) = text
292 .strip_prefix("##[error]Process completed with exit code ")
293 .and_then(|code| code.strip_suffix('.'))
294 .and_then(|code| code.parse().ok())
295 {
296 section.exit_code.get_or_insert(code);
297 }
298 }
299 }
300 sections
301}
302
303fn section_of<'log>(
306 job: &api::Job,
307 sections: &'log [Section],
308 step: &api::Step,
309) -> Option<&'log Section> {
310 if let Some(title) = step.name.strip_prefix("Run ")
311 && let Some(section) = sections.iter().find(|section| section.title == title)
312 {
313 return Some(section);
314 }
315 if step.conclusion.as_deref() == Some("failure")
316 && let Some(section) = sections.iter().rfind(|section| section.exit_code.is_some())
317 {
318 return Some(section);
319 }
320 let place = job
321 .steps
322 .iter()
323 .filter(|candidate| {
324 candidate.number > 1
325 && !matches!(candidate.conclusion.as_deref(), Some("skipped") | None)
326 })
327 .position(|candidate| candidate.number == step.number)?;
328 sections.get(place)
329}
330
331fn plain(line: &str) -> String {
333 let text = match line.split_once(' ') {
334 Some((stamp, rest)) if stamp.ends_with('Z') && stamp.contains('T') => rest,
335 _ => line,
336 };
337 let mut plain = String::with_capacity(text.len());
338 let mut chars = text.chars();
339 while let Some(c) = chars.next() {
340 if c == '\u{1b}' {
341 for c in chars.by_ref() {
343 if c == 'm' {
344 break;
345 }
346 }
347 } else {
348 plain.push(c);
349 }
350 }
351 plain
352}
353
354#[cfg(test)]
355mod tests;