Skip to main content

runnerd_core/
record.rs

1//! Records of failed jobs: which runner containers may be kept, the files a record must never hold, which records go
2//! to keep within `[records]`, and what a shell in a record shows first. Pure functions: the engine
3//! (`scheduler::engine`) commits, lists and removes the images, and the daemon serves the shell.
4//!
5//! A record is the runner container's own layer, committed as the image `runnerd-record:<job>` once the runner
6//! exited: the workspace and everything else the job wrote outside the slot's volumes. When the runner exits, the
7//! runner image's entrypoint deletes what the job added or changed among the runner's dot-files and in the temporary
8//! directories, and the step scripts (`image/scrub.sh`); before a commit, `secrets_left` checks the container's
9//! changed paths and the engine keeps no record that still holds one.
10
11use std::collections::BTreeSet;
12use std::fmt::Write as _;
13
14use crate::api;
15use crate::config;
16
17/// The repository of every record image; the tag is the job's ID.
18pub const IMAGE: &str = "runnerd-record";
19
20/// The label every record image carries, with the job's ID.
21pub const LABEL: &str = "runnerd.record";
22
23/// The label every shell container carries, with the job's ID.
24pub const SHELL_LABEL: &str = "runnerd.shell";
25
26/// The image of a job's record.
27pub fn image(job: u64) -> String {
28    format!("{IMAGE}:{job}")
29}
30
31/// What the runner prints when its job failed. Its exit code says nothing about the job.
32const FAILED: &str = "completed with result: Failed";
33
34/// Whether a runner's output says its job failed.
35pub fn failed(runner_log: &str) -> bool {
36    runner_log.lines().any(|line| line.ends_with(FAILED))
37}
38
39/// The runner's home, whose dot-files (`.runner`, `.credentials`, `.gitconfig`, `.aws`, `.gnupg`, …) a job's changes
40/// must not leave in a record: the runner keeps its registration and credentials there, and tools their logins and
41/// keys.
42const HOME: &str = "/home/runner/";
43
44/// Directories nothing a job changed in may be kept: the JIT config's, the step scripts' (`GITHUB_ENV` and the
45/// credentials `actions/checkout` and `setup-node` leave), and the temporary directories, where a job may put a key
46/// or a kubeconfig.
47const SECRET_DIRS: &[&str] = &[
48    "/run/runnerd",
49    "/home/runner/_work/_temp",
50    "/tmp",
51    "/var/tmp",
52];
53
54/// Directories among the runner's dot-files a record keeps, as the runner image's scrub does: the Rust toolchains
55/// rustup and cargo install (`dtolnay/rust-toolchain`), which a shell needs to re-run a step, and cargo's downloaded
56/// crates and git checkouts, which hold nothing a workspace's `Cargo.lock` does not name. Not `~/.cargo`'s
57/// `credentials.toml` or `config.toml`, which may hold a registry's token.
58const HARMLESS: &[&str] = &[
59    "/home/runner/.rustup",
60    "/home/runner/.cargo/bin",
61    "/home/runner/.cargo/registry",
62    "/home/runner/.cargo/git",
63];
64
65/// Whether `path` is in a `HARMLESS` directory, or is one of them or a parent of one that `changed` shows a path in:
66/// a directory's own entry holds nothing.
67fn 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
77/// The changed paths of a runner container that a record must not hold: anything added or modified among the
78/// runner's dot-files but the `HARMLESS` toolchains, in the temporary directories, or where the runner keeps the
79/// JIT config and step scripts. A record of it must not be made. `changed` are the paths Docker reports added or
80/// modified.
81pub 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
99/// The first few of `paths`, and how many more there are: a container without the scrub may have changed thousands.
100pub 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
114/// Which records go, and why, before a new record of `incoming` bytes is made; or why it is not made. Records in
115/// `open` have a shell open and stay. `free` is the disk Docker has left, when known. Expired records go too.
116pub 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
175/// The records older than `max_age` at `now`, but those with a shell open.
176pub 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
192/// The directory a job's steps run in by default: `GITHUB_WORKSPACE`.
193pub fn workspace(repository: &str) -> String {
194    let name = repository.rsplit('/').next().unwrap_or(repository);
195    format!("/home/runner/_work/{name}/{name}")
196}
197
198/// What a shell in a job's record prints before its prompt: the job, the step (`step`, or the one that failed),
199/// what the step ran as its log shows it, and what the record lacks. `log` is the job's stored log. Lines end in
200/// `\r\n`, for a terminal in raw mode.
201pub 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, &sections, 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 &section.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/// A top-level `##[group]Run …` section of a job's log: one step that ran.
264#[derive(Debug, Default)]
265struct Section {
266    /// Its title without `Run `.
267    title: String,
268    /// The group's lines: what the step ran, its shell and its environment.
269    lines: Vec<String>,
270    /// From `##[error]Process completed with exit code N.`.
271    exit_code: Option<i64>,
272}
273
274/// The `##[group]Run …` sections of a log, in order.
275fn 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
303/// The section of a step: the one titled as the step is named (`Run cargo test`), else for a failed step the one
304/// that ended with an exit code, else the one at the step's place among the steps that ran after `Set up job`.
305fn 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
331/// A stored log line without its timestamp and colour codes.
332fn 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            // An SGR sequence: ESC [ … m.
342            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;