chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
knotless-run: A container that fails to start leaves its log
crates/knotless-run/src/executor.rs+3−1
| @@ -352,13 +352,15 @@ async fn container( | ||
// Kept running until the run ends, for each step to be run in. | ||
cmd.args([image, "tail", "-f", "/dev/null"]) | ||
.stdin(Stdio::null()) | ||
.stderr(log.try_clone()?) | ||
.kill_on_drop(true); | ||
let started = tokio::time::timeout(timeout, cmd.output()).await; | ||
let out = match started { | ||
Ok(out) => out.context("starting a container")?, | ||
Err(_) => bail!("{image} did not start in {}s", timeout.as_secs()), | ||
}; | ||
// Taking its output pipes what docker says too, whatever the command | ||
// was given: it goes to the log here. | ||
std::io::Write::write_all(&mut &*log, &out.stderr)?; | ||
if !out.status.success() { | ||
bail!("{image} did not start: see the log"); | ||
} | ||
crates/knotless-run/src/lib.rs+14−1
| @@ -2974,7 +2974,20 @@ impl Runner { | ||
}; | ||
let timeout = self.opts.step_timeout; | ||
let _tracked = Tracked::new(self, setup.owner.run); | ||
let session = self.opts.executor.start(&setup, &log, timeout).await?; | ||
// A start that fails says so in the log its error points at, and | ||
// the log is kept before the check is filed as an error. | ||
let session = match self.opts.executor.start(&setup, &log, timeout).await { | ||
Ok(session) => session, | ||
Err(err) => { | ||
// The start's error is what the check says, whatever | ||
// keeping the log comes to. | ||
let _ = log.write_all(format!("==> {err:#}\n").as_bytes()); | ||
let text = std::fs::read(&log_path).unwrap_or_default(); | ||
let text = check::redact(&text, &secrets); | ||
check.log = self.reporter.upload_log(&text).await.ok(); | ||
return Err(err); | ||
} | ||
}; | ||
// A run on its schedule is held to the runner's limits, as one | ||
// that recurs on its own: only its owner's own ask goes past them. | ||
let scheduled = given(subject, wf).any(|(k, _)| k == SCHEDULE_VAR); | ||
crates/knotless-run/tests/run.rs+57−0
| @@ -897,6 +897,63 @@ fn check_on(runner: &Sandbox, owner: &Sandbox, key: &str, r: &str, wf: &str) -> | ||
.unwrap_or_default() | ||
} | ||
| ||
/// A container that fails to start leaves its log, with docker's words and | ||
/// the runner's, where the check's error says to look. | ||
#[test] | ||
fn a_start_that_fails_leaves_its_log() { | ||
let owner = Sandbox::new(); | ||
let mut runner = Sandbox::new(); | ||
let handle = runner.account.handle.clone(); | ||
runner.set("KNOTLESS_IDENTIFIER", &handle); | ||
// A docker that refuses everything, found before any other. | ||
let bin = runner.path("bin"); | ||
std::fs::create_dir_all(&bin).unwrap(); | ||
let fake = bin.join("docker"); | ||
std::fs::write( | ||
&fake, | ||
"#!/bin/sh\necho 'docker: no space left' >&2\nexit 1\n", | ||
) | ||
.unwrap(); | ||
std::fs::set_permissions(&fake, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); | ||
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()); | ||
// Only a prefix of the path, so the other tests here see no change. | ||
unsafe { std::env::set_var("PATH", path) }; | ||
// The repository runner_opts watches. | ||
let work = owner.work("proj", 1); | ||
owner.git(&work, &["remote", "add", "origin", &owner.url("proj")]); | ||
owner.git(&work, &["push", "-q", "origin", "main"]); | ||
let (frames, _) = tokio::sync::mpsc::unbounded_channel::<Message>(); | ||
let js = Jetstream { | ||
url: "ws://127.0.0.1:9".into(), | ||
frames, | ||
}; | ||
let mut opts = runner_opts(&runner, &owner, &js); | ||
opts.executor = Executor::parse("docker", "alpine").unwrap(); | ||
let rt = tokio::runtime::Runtime::new().unwrap(); | ||
let (run, watches) = rt | ||
.block_on(knotless_run::Runner::start(opts, common::ctx(&runner))) | ||
.unwrap(); | ||
let watch = &watches[0]; | ||
// The first pass only learns where the refs are: the push after it runs. | ||
rt.block_on(run.reconcile(watch)).unwrap(); | ||
let dir = work.join(".knotless/workflows"); | ||
std::fs::create_dir_all(&dir).unwrap(); | ||
std::fs::write(dir.join("boot.yml"), "steps: [{run: 'true'}]\n").unwrap(); | ||
owner.git(&work, &["add", "."]); | ||
owner.git(&work, &["commit", "-q", "-m", "workflow"]); | ||
owner.git(&work, &["push", "-q", "origin", "main"]); | ||
rt.block_on(run.reconcile(watch)).unwrap(); | ||
rt.block_on(run.idle()); | ||
let boot = check_on(&runner, &owner, &watch.key, "refs/heads/main", "boot"); | ||
assert_eq!(boot["status"], "error", "{boot}"); | ||
let log = blob( | ||
&runner.account.did, | ||
boot["log"]["ref"]["$link"].as_str().unwrap(), | ||
); | ||
assert!(log.contains("docker: no space left"), "{log}"); | ||
assert!(log.contains("see the log"), "{log}"); | ||
} | ||
| ||
#[test] | ||
fn what_goes_wrong_is_reported() { | ||
let owner = Sandbox::new(); | ||
docs/design/runner.md+2−1
| @@ -570,7 +570,8 @@ exec` is given the secrets that step may see, the same way, so the | ||
container never holds one and a step cannot read another's from | ||
`/proc/1/environ`. Starting the container, image pull included, writes | ||
to the run's log and is limited to `--step-timeout`. A container that does | ||
not start makes the check `error`. | ||
not start makes the check `error`, and the runner ends the log with why | ||
and uploads it, so the check has a log to read. | ||
| ||
Under `shell`, each step is `sh -ec <script>` in the checkout. When the | ||
run ends, the container is removed, which kills anything a step | ||