use std::collections::BTreeSet;
use std::fmt::Write as _;
use crate::api;
use crate::config;
pub const IMAGE: &str = "runnerd-record";
pub const LABEL: &str = "runnerd.record";
pub const SHELL_LABEL: &str = "runnerd.shell";
pub fn image(job: u64) -> String {
format!("{IMAGE}:{job}")
}
const FAILED: &str = "completed with result: Failed";
pub fn failed(runner_log: &str) -> bool {
runner_log.lines().any(|line| line.ends_with(FAILED))
}
const HOME: &str = "/home/runner/";
const SECRET_DIRS: &[&str] = &[
"/run/runnerd",
"/home/runner/_work/_temp",
"/tmp",
"/var/tmp",
];
const HARMLESS: &[&str] = &[
"/home/runner/.rustup",
"/home/runner/.cargo/bin",
"/home/runner/.cargo/registry",
"/home/runner/.cargo/git",
];
fn harmless(path: &str, changed: &[&str]) -> bool {
let below = |dir: &str, path: &str| {
path.strip_prefix(dir)
.is_some_and(|rest| rest.starts_with('/'))
};
let on_the_way = HARMLESS.iter().any(|dir| *dir == path || below(path, dir));
HARMLESS.iter().any(|dir| below(dir, path))
|| on_the_way && changed.iter().any(|other| below(path, other))
}
pub fn secrets_left<'path>(changed: impl IntoIterator<Item = &'path str>) -> Vec<String> {
let changed: Vec<&str> = changed.into_iter().collect();
changed
.iter()
.copied()
.filter(|path| !harmless(path, &changed))
.filter(|path| {
path.strip_prefix(HOME)
.is_some_and(|rest| rest.starts_with('.'))
|| SECRET_DIRS.iter().any(|dir| {
path.strip_prefix(dir)
.is_some_and(|rest| rest.starts_with('/'))
})
})
.map(str::to_owned)
.collect()
}
pub fn listed(paths: &[String]) -> String {
const SHOWN: usize = 5;
let mut text = paths
.iter()
.take(SHOWN)
.map(String::as_str)
.collect::<Vec<_>>()
.join(", ");
if paths.len() > SHOWN {
write!(text, " and {} more", paths.len() - SHOWN).ok();
}
text
}
pub fn make_room(
records: &[api::Record],
open: &BTreeSet<u64>,
limits: &config::Records,
incoming: u64,
free: Option<u64>,
now: u64,
) -> Result<Vec<(u64, api::RecordRemoval)>, String> {
if incoming > limits.max_total.0 {
return Err(format!(
"it would take {}, more than records.max_total ({})",
config::ByteSize(incoming),
limits.max_total
));
}
let mut removed = expired(records, open, limits, now);
let mut kept: Vec<&api::Record> = records
.iter()
.filter(|record| !removed.iter().any(|(job, _)| *job == record.job))
.collect();
kept.sort_by_key(|record| (record.created_at, record.job));
let mut removable = kept
.clone()
.into_iter()
.filter(|record| !open.contains(&record.job));
let max_count = usize::try_from(limits.max_count).unwrap_or(usize::MAX);
let mut count = kept.len();
let mut total: u64 = kept.iter().map(|record| record.bytes).sum();
while count >= max_count || total + incoming > limits.max_total.0 {
let why = if count >= max_count {
api::RecordRemoval::Count
} else {
api::RecordRemoval::Size
};
let Some(oldest) = removable.next() else {
return Err("the records kept within the limits have shells open".to_owned());
};
removed.push((oldest.job, why));
count -= 1;
total -= oldest.bytes;
}
if let Some(free) = free {
let freed: u64 = records
.iter()
.filter(|record| removed.iter().any(|(job, _)| *job == record.job))
.map(|record| record.bytes)
.sum();
if (free + freed).saturating_sub(incoming) < limits.min_free.0 {
return Err(format!(
"Docker has {} free; a record of {} would leave less than records.min_free ({})",
config::ByteSize(free),
config::ByteSize(incoming),
limits.min_free
));
}
}
Ok(removed)
}
pub fn expired(
records: &[api::Record],
open: &BTreeSet<u64>,
limits: &config::Records,
now: u64,
) -> Vec<(u64, api::RecordRemoval)> {
records
.iter()
.filter(|record| {
!open.contains(&record.job)
&& now.saturating_sub(record.created_at) > limits.max_age.as_secs()
})
.map(|record| (record.job, api::RecordRemoval::Age))
.collect()
}
pub fn workspace(repository: &str) -> String {
let name = repository.rsplit('/').next().unwrap_or(repository);
format!("/home/runner/_work/{name}/{name}")
}
pub fn banner(job: &api::Job, log: &[String], step: Option<u32>) -> String {
let summary = &job.summary;
let mut text = format!(
"runnerd: the record of job {} of {}",
summary.id, summary.repository
);
match &summary.workflow {
Some(workflow) => write!(text, ", {workflow} / {}", summary.name),
None => write!(text, ", {}", summary.name),
}
.ok();
text.push('\n');
let failed = job
.steps
.iter()
.find(|candidate| candidate.conclusion.as_deref() == Some("failure"));
let shown = match step {
Some(number) => job
.steps
.iter()
.find(|candidate| candidate.number == number),
None => failed,
};
let sections = sections(log);
match shown {
Some(shown) => {
let verb = if failed.is_some_and(|failed| failed.number == shown.number) {
"failed at"
} else {
"shows"
};
write!(text, "It {verb} step {}: {}", shown.number, shown.name).ok();
let section = section_of(job, §ions, shown);
if let Some(code) = section.and_then(|section| section.exit_code) {
write!(text, " (exit code {code})").ok();
}
text.push('\n');
match section {
Some(section) => {
text.push_str("Its log shows it ran:\n");
for line in §ion.lines {
writeln!(text, " {line}").ok();
}
}
None => text.push_str("Its log does not show what it ran.\n"),
}
}
None if step.is_some() => text.push_str("The job has no such step.\n"),
None => text.push_str("The job's steps do not say which one failed.\n"),
}
writeln!(
text,
"You are in {}, as the job left it. Not kept: the job's Docker containers and images; what it changed \
in ~/.cache, the tool cache and the Android SDK, which show the profile's snapshot; and what it added or \
changed among your home's dot-files and in /tmp (logins, and tools in ~/.local or ~/.nvm), except \
~/.rustup and ~/.cargo's bin, registry and git. Exit to remove this container.",
workspace(&summary.repository)
)
.ok();
text.replace('\n', "\r\n")
}
#[derive(Debug, Default)]
struct Section {
title: String,
lines: Vec<String>,
exit_code: Option<i64>,
}
fn sections(log: &[String]) -> Vec<Section> {
let mut sections: Vec<Section> = Vec::new();
let mut in_group = false;
for line in log {
let text = plain(line);
if let Some(title) = text.strip_prefix("##[group]Run ") {
sections.push(Section {
title: title.to_owned(),
..Section::default()
});
in_group = true;
} else if text.starts_with("##[endgroup]") {
in_group = false;
} else if let Some(section) = sections.last_mut() {
if in_group {
section.lines.push(text);
} else if let Some(code) = text
.strip_prefix("##[error]Process completed with exit code ")
.and_then(|code| code.strip_suffix('.'))
.and_then(|code| code.parse().ok())
{
section.exit_code.get_or_insert(code);
}
}
}
sections
}
fn section_of<'log>(
job: &api::Job,
sections: &'log [Section],
step: &api::Step,
) -> Option<&'log Section> {
if let Some(title) = step.name.strip_prefix("Run ")
&& let Some(section) = sections.iter().find(|section| section.title == title)
{
return Some(section);
}
if step.conclusion.as_deref() == Some("failure")
&& let Some(section) = sections.iter().rfind(|section| section.exit_code.is_some())
{
return Some(section);
}
let place = job
.steps
.iter()
.filter(|candidate| {
candidate.number > 1
&& !matches!(candidate.conclusion.as_deref(), Some("skipped") | None)
})
.position(|candidate| candidate.number == step.number)?;
sections.get(place)
}
fn plain(line: &str) -> String {
let text = match line.split_once(' ') {
Some((stamp, rest)) if stamp.ends_with('Z') && stamp.contains('T') => rest,
_ => line,
};
let mut plain = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for c in chars.by_ref() {
if c == 'm' {
break;
}
}
} else {
plain.push(c);
}
}
plain
}
#[cfg(test)]
mod tests;