brokerd: the Podman runtime

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-23 08:51:44 -07:00
parent 4a1c6fa0a5
commit cfe13787b0
5 changed files with 651 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
//! The Podman runtime: one fresh container per call, with the limits of `[runner]`. Whatever the
//! tool prints is the result's content; every failure is a fixed sentence, and what Podman itself
//! said goes only to `brokerd`'s log. M3b spec, section 6.
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use crate::config::Runner;
use crate::podman;
use crate::runner::{RunError, RunOutput, RunSpec, Runtime};
pub const RUNBOOK: &str = "see docs/runbook.md#runner-unavailable";
pub const COULD_NOT_RUN: &str = "the tool could not run";
pub const CANNOT_START: &str = "the tool runner could not start the container";
pub const KILLED: &str = "the tool was stopped: it ran out of memory or was killed";
pub const TIMED_OUT: &str = "the tool ran past its time limit";
pub const UNEXPECTED: &str = "the tool failed with an unexpected status";
/// How often a running container is checked.
pub const POLL: Duration = Duration::from_millis(50);
/// How much of Podman's standard error is kept for the log.
pub const STDERR_KEPT: usize = 4096;
/// A log sink. Call it as `(self.log)("a line")`.
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
pub struct Podman {
runner: Runner,
egress_dir: PathBuf,
log: Log,
next: AtomicU64,
}
impl Podman {
/// `egress_dir` is `Config::egress_dir()`; task 12 uses it.
pub fn new(runner: Runner, egress_dir: PathBuf, log: Log) -> Podman {
Podman {
runner,
egress_dir,
log,
next: AtomicU64::new(0),
}
}
/// Where `http_fetch` calls get their directories. Public, so the field counts as read.
pub fn egress_dir(&self) -> &Path {
&self.egress_dir
}
/// Run one short `podman` command (`kill`, `rm -f`) whose result only matters for the log.
pub(crate) fn podman(&self, args: &[&str]) {
let what = args.join(" ");
let podman = &self.runner.podman;
match Command::new(podman)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
{
Ok(status) if status.success() => {}
Ok(status) => (self.log)(&format!("brokerd: podman {what} exited {status}")),
Err(e) => (self.log)(&format!("brokerd: podman {what} failed: {e}")),
}
}
/// Wait for `child` until `limit` has passed since `started`. `Some(status)` if it ended;
/// `None` if it ran too long, after `podman kill <name>`, `podman rm -f <name>`,
/// `child.kill()` and `child.wait()` (step 5).
fn wait(
&self,
child: &mut Child,
name: &str,
started: Instant,
limit: Duration,
) -> Option<ExitStatus> {
loop {
if let Ok(Some(status)) = child.try_wait() {
return Some(status);
}
if started.elapsed() >= limit {
self.podman(&["kill", name]);
self.podman(&["rm", "-f", name]);
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(POLL);
}
}
}
impl Podman {
/// Steps 3 to 7 for one container: spawn, feed and read it, wait, answer. Task 12 calls it too.
/// Already written: it only joins the helpers below.
fn run_container(
&self,
name: &str,
args: Vec<OsString>,
input: String,
limit: Duration,
) -> Result<RunOutput, RunError> {
let Some(mut child) = self.spawn(args) else {
return Err(RunError::Unavailable(CANNOT_START.to_string()));
};
let started = Instant::now();
let cap = usize::try_from(self.runner.output_cap).unwrap_or(usize::MAX);
let io = Io::start(&mut child, input, cap);
let status = self.wait(&mut child, name, started, limit);
let (out, truncated, err) = io.finish();
self.answer(name, status, out, truncated, &err)
}
/// Step 3: spawn `podman` with `args` and all three standard streams piped. On failure, log
/// `brokerd: cannot start {podman path}: {e}` + "\n" + RUNBOOK and return `None`.
fn spawn(&self, args: Vec<OsString>) -> Option<Child> {
let podman = &self.runner.podman;
match Command::new(podman)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => Some(child),
Err(e) => {
(self.log)(&format!(
"brokerd: cannot start {}: {}\n{}",
podman.display(),
e,
RUNBOOK
));
None
}
}
}
/// Step 7: the answer, by the table in the task. `status` is `None` when the time limit was
/// passed. `out` is the kept standard output, `err` the kept standard error (for the log only:
/// it never goes into a `RunError`).
fn answer(
&self,
name: &str,
status: Option<ExitStatus>,
out: Vec<u8>,
truncated: bool,
err: &str,
) -> Result<RunOutput, RunError> {
match status {
None => {
(self.log)(&format!(
"brokerd: stopped container {}: it ran past its time limit",
name
));
Err(RunError::Failed(TIMED_OUT.to_string()))
}
Some(status) => match status.code() {
Some(0) | Some(1) => Ok(RunOutput {
content: String::from_utf8_lossy(&out).into_owned(),
truncated,
}),
Some(2) => {
(self.log)(err);
Err(RunError::Failed(COULD_NOT_RUN.to_string()))
}
Some(125..=127) => {
(self.log)(&format!("{err}\n{RUNBOOK}"));
Err(RunError::Unavailable(CANNOT_START.to_string()))
}
Some(137) => Err(RunError::Failed(KILLED.to_string())),
_ => {
(self.log)(&format!("brokerd: container {name} exited {status}\n{err}"));
Err(RunError::Failed(UNEXPECTED.to_string()))
}
},
}
}
}
impl Runtime for Podman {
/// Steps 1 and 2, then `run_container`. Already written.
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
let n = self.next.fetch_add(1, Ordering::SeqCst);
let name = podman::container_name(spec.session(), spec.call(), n);
let args = podman::tool_args(spec, &self.runner, &name, None);
let input = spec.arguments().canonical_json();
let limit = self.runner.time_limit(spec.tool());
self.run_container(&name, args, input, limit)
}
}
/// Step 4: the three threads that feed and read one container, so no pipe can block another.
struct Io {
writer: Option<JoinHandle<()>>,
stdout: Option<JoinHandle<(Vec<u8>, bool)>>,
stderr: Option<JoinHandle<(Vec<u8>, bool)>>,
}
impl Io {
/// Take `child`'s three pipes (`child.stdin.take()` and so on) and start one thread for each:
/// write `input` to standard input and then drop it; `read_capped` standard output with `cap`;
/// `read_capped` standard error with `STDERR_KEPT`. A pipe that is `None` gets no thread.
fn start(child: &mut Child, input: String, cap: usize) -> Io {
let writer = child.stdin.take().map(|mut stdin| {
std::thread::spawn(move || {
let _ = stdin.write_all(input.as_bytes());
})
});
let stdout = child
.stdout
.take()
.map(|stdout| std::thread::spawn(move || read_capped(stdout, cap)));
let stderr = child
.stderr
.take()
.map(|stderr| std::thread::spawn(move || read_capped(stderr, STDERR_KEPT)));
Io {
writer,
stdout,
stderr,
}
}
/// Step 6: join the three threads. A thread that is missing or panicked counts as empty
/// (`join().ok()`, `unwrap_or_default()`). Returns the kept standard output, whether there was
/// more, and the kept standard error decoded with `from_utf8_lossy`.
fn finish(self) -> (Vec<u8>, bool, String) {
if let Some(handle) = self.writer {
let _ = handle.join();
}
let (out, truncated) = match self.stdout {
Some(handle) => handle.join().ok().unwrap_or_default(),
None => (Vec::new(), false),
};
let err = match self.stderr {
Some(handle) => {
let (bytes, _) = handle.join().ok().unwrap_or_default();
String::from_utf8_lossy(&bytes).into_owned()
}
None => String::new(),
};
(out, truncated, err)
}
}
/// Everything `from` gives, keeping the first `cap` bytes; `true` if there was more. Reads on past
/// the cap, so the writer is never blocked or broken by a closed pipe. No indexing: `buf.get(..n)`.
fn read_capped(from: impl Read, cap: usize) -> (Vec<u8>, bool) {
let mut from = from;
let mut kept: Vec<u8> = Vec::new();
let mut truncated = false;
let mut chunk = [0u8; 8192];
loop {
let n = match from.read(&mut chunk) {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
let remaining = cap - kept.len();
if remaining == 0 {
truncated = true;
} else {
let take = remaining.min(n);
kept.extend_from_slice(&chunk[..take]);
if n > take {
truncated = true;
}
}
}
(kept, truncated)
}
+1
View File
@@ -6,6 +6,7 @@ pub mod args;
pub mod audit;
pub mod broker;
pub mod config;
pub mod container;
pub mod grants;
pub mod ledger;
pub mod podman;
+271
View File
@@ -0,0 +1,271 @@
//! The Podman runtime against a fake `podman`: what it is given, and what each way a container can
//! end becomes (M3b spec, section 6). Every call goes through `runner::run`, as in `brokerd`.
//! Do not edit.
#[path = "support/build.rs"]
mod build;
#[path = "support/fake_podman.rs"]
mod fake_podman;
use std::time::{Duration, Instant};
use brokerd::container::{
CANNOT_START, COULD_NOT_RUN, KILLED, Podman, RUNBOOK, TIMED_OUT, UNEXPECTED,
};
use brokerd::policy::{Outcome, SessionState, decide};
use brokerd::runner::run;
use build::{grant, now, read, request, set};
use fake_podman::{Fake, Lines, serial};
use proto::{DataClass, Mode, ToolRequest, ToolResponse};
fn call(podman: &Podman, req: ToolRequest, grants: Vec<build::Build>) -> ToolResponse {
let decision = match decide(req, &set(grants), SessionState::default(), now()) {
Outcome::Allowed(d) => d,
other => panic!("not allowed: {other:?}"),
};
run(decision, podman)
}
fn notes() -> Vec<build::Build> {
vec![grant("notes", "read_file", Mode::Auto).paths(&["/n"])]
}
fn podman(fake: &Fake, extra: &str, log: &Lines) -> Podman {
Podman::new(fake.runner(extra), fake.dir.join("egress"), log.sink())
}
fn failed(message: &str) -> ToolResponse {
ToolResponse::Failed {
message: message.to_string(),
}
}
#[test]
fn a_tool_that_succeeds_is_a_result_labelled_by_its_grant() {
let _s = serial();
let fake = Fake::new("ok", r#"cat > "$D/stdin"; printf 'the file text'; exit 0"#);
let log = Lines::default();
let got = call(&podman(&fake, "", &log), read("/n/a.md"), notes());
assert_eq!(
got,
ToolResponse::Result {
content: "the file text".to_string(),
class: DataClass::Private,
untrusted: true,
truncated: false,
}
);
assert_eq!(
fake.stdin(),
r#"{"path":"/n/a.md"}"#,
"the arguments go on standard input"
);
}
#[test]
fn podman_is_given_the_tool_argument_list_and_the_first_container_is_numbered_0() {
let _s = serial();
let fake = Fake::new("args", r#"cat > /dev/null; exit 0"#);
let log = Lines::default();
let p = podman(&fake, "", &log);
call(&p, read("/n/a.md"), notes());
call(&p, read("/n/b.md"), notes());
let calls = fake.calls();
assert_eq!(
calls.len(),
2,
"one podman run per call and nothing else: {calls:?}"
);
assert_eq!(calls[0][3], "--name=boxmaker-s1-1-0");
assert_eq!(calls[1][3], "--name=boxmaker-s1-1-1");
// The whole list is `podman::tool_args`, tested as golden files in podman_args.rs.
assert_eq!(calls[0].first().map(String::as_str), Some("run"));
assert_eq!(calls[0].last().map(String::as_str), Some("read_file"));
assert!(calls[0].contains(&"--volume=/n:/n:ro".to_string()));
}
#[test]
fn exit_1_is_the_tools_own_error_and_still_a_result() {
let _s = serial();
let fake = Fake::new(
"e1",
"cat > /dev/null; printf 'read_file: /n/x: no such file'; exit 1",
);
let log = Lines::default();
let got = call(&podman(&fake, "", &log), read("/n/x"), notes());
assert!(
matches!(&got, ToolResponse::Result { content, .. } if content == "read_file: /n/x: no such file"),
"{got:?}"
);
}
#[test]
fn every_other_ending_is_a_fixed_sentence() {
let cases = [
("2", COULD_NOT_RUN),
("125", CANNOT_START),
("126", CANNOT_START),
("127", CANNOT_START),
("137", KILLED),
("3", UNEXPECTED),
("124", UNEXPECTED),
];
for (code, sentence) in cases {
let _s = serial();
let body = format!(
"cat > /dev/null; printf 'secret tool output'; echo 'podman said this' >&2; exit {code}"
);
let fake = Fake::new("codes", &body);
let log = Lines::default();
let got = call(&podman(&fake, "", &log), read("/n/a"), notes());
assert_eq!(got, failed(sentence), "exit {code}");
}
}
#[test]
fn a_podman_failure_is_logged_with_the_runbook_pointer_and_its_stderr() {
let _s = serial();
let fake = Fake::new(
"125",
"cat > /dev/null; echo 'Error: image not known' >&2; exit 125",
);
let log = Lines::default();
call(&podman(&fake, "", &log), read("/n/a"), notes());
let text = log.all();
assert!(text.contains("Error: image not known"), "{text}");
assert!(text.contains(RUNBOOK), "{text}");
}
#[test]
fn a_podman_that_cannot_be_started_is_unavailable_and_logged() {
let _s = serial();
let fake = Fake::new("missing", "exit 0");
let mut runner = fake.runner("");
runner.podman = fake.dir.join("no-such-podman");
let log = Lines::default();
let p = Podman::new(runner, fake.dir.join("egress"), log.sink());
assert_eq!(call(&p, read("/n/a"), notes()), failed(CANNOT_START));
assert!(log.all().contains(RUNBOOK), "{}", log.all());
}
#[test]
fn output_past_the_cap_is_cut_and_marked() {
let _s = serial();
let fake = Fake::new(
"cap",
"cat > /dev/null; head -c 1000 /dev/zero | tr '\\0' x; exit 0",
);
let log = Lines::default();
let got = call(
&podman(&fake, "output_cap = 100", &log),
read("/n/a"),
notes(),
);
assert!(
matches!(&got, ToolResponse::Result { content, truncated: true, .. } if *content == "x".repeat(100)),
"{got:?}"
);
let exact = Fake::new(
"cap-exact",
"cat > /dev/null; head -c 100 /dev/zero | tr '\\0' x; exit 0",
);
let got = call(
&podman(&exact, "output_cap = 100", &log),
read("/n/a"),
notes(),
);
assert!(
matches!(
&got,
ToolResponse::Result {
truncated: false,
..
}
),
"{got:?}"
);
}
#[test]
fn output_that_is_not_utf8_is_replaced() {
let _s = serial();
let fake = Fake::new("utf8", "cat > /dev/null; printf 'a\\377b'; exit 0");
let log = Lines::default();
let got = call(&podman(&fake, "", &log), read("/n/a"), notes());
assert!(
matches!(&got, ToolResponse::Result { content, .. } if content == "a\u{fffd}b"),
"{got:?}"
);
}
#[test]
fn a_tool_past_its_time_limit_is_killed_removed_and_failed() {
let _s = serial();
// `exec`, so killing the process kills the sleep and nothing holds the pipes open.
let fake = Fake::new("slow", "cat > /dev/null; exec sleep 30");
let log = Lines::default();
let started = Instant::now();
let got = call(
&podman(&fake, "read_file_ms = 300", &log),
read("/n/a"),
notes(),
);
let took = started.elapsed();
assert_eq!(got, failed(TIMED_OUT));
assert!(took >= Duration::from_millis(300), "{took:?}");
assert!(took < Duration::from_secs(5), "{took:?}");
let calls = fake.calls();
assert_eq!(calls.len(), 3, "{calls:?}");
assert_eq!(calls[1], ["kill", "boxmaker-s1-1-0"]);
assert_eq!(calls[2], ["rm", "-f", "boxmaker-s1-1-0"]);
}
#[test]
fn a_large_argument_is_written_whole_while_the_tool_reads_it() {
let _s = serial();
let fake = Fake::new("big", r#"cat > "$D/stdin"; printf done; exit 0"#);
let log = Lines::default();
let content = "y".repeat(900_000);
let req = request(
"write_file",
&format!(r#"{{"path":"/w/big.txt","content":"{content}"}}"#),
);
let got = call(
&podman(&fake, "", &log),
req,
vec![grant("w", "write_file", Mode::Auto).paths(&["/w"])],
);
assert!(
matches!(&got, ToolResponse::Result { content, .. } if content == "done"),
"{got:?}"
);
assert_eq!(
fake.stdin().len(),
content.len() + r#"{"path":"/w/big.txt","content":""}"#.len()
);
}
#[test]
fn a_tool_that_never_reads_its_input_still_ends() {
let _s = serial();
let fake = Fake::new("noread", "printf ignored; exit 0");
let log = Lines::default();
let req = request(
"write_file",
&format!(
r#"{{"path":"/w/big.txt","content":"{}"}}"#,
"z".repeat(900_000)
),
);
let started = Instant::now();
let got = call(
&podman(&fake, "write_file_ms = 5000", &log),
req,
vec![grant("w", "write_file", Mode::Auto).paths(&["/w"])],
);
assert!(
matches!(&got, ToolResponse::Result { content, .. } if content == "ignored"),
"{got:?}"
);
assert!(started.elapsed() < Duration::from_secs(4));
}
+102
View File
@@ -0,0 +1,102 @@
//! A fake `podman` for the runtime tests: a shell script that records every call's arguments and,
//! for `run`, does what the test says. Do not edit.
//!
//! Included with `#[path = "support/fake_podman.rs"] mod fake_podman;`.
#![allow(dead_code)] // each test file uses a different part of this module
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use brokerd::config::{Config, Runner};
static NEXT: AtomicU32 = AtomicU32::new(0);
static SERIAL: Mutex<()> = Mutex::new(());
/// Tests that write a script and run it take turns. Otherwise another test's fork can hold the
/// script open for writing at the moment it is run, and running it fails with "text file busy"
/// (ETXTBSY), which has nothing to do with the code under test.
pub fn serial() -> MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(|p| p.into_inner())
}
pub const IMAGE: &str = "localhost/boxmaker-tools@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
pub struct Fake {
pub dir: PathBuf,
pub script: PathBuf,
}
impl Drop for Fake {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
impl Fake {
/// A fake whose `run` does `run_body` (a shell fragment; `$D` is the fake's directory). Every
/// other command (`kill`, `rm`) is recorded and succeeds.
pub fn new(tag: &str, run_body: &str) -> Fake {
let n = NEXT.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("bx-fp-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("podman");
let text = format!(
"#!/bin/sh\nD='{}'\nfor a in \"$@\"; do printf '%s\\n' \"$a\"; done >> \"$D/calls\"\necho --- >> \"$D/calls\"\n[ \"$1\" = run ] || exit 0\n{run_body}\n",
dir.display()
);
std::fs::write(&script, text).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
Fake { dir, script }
}
/// Every call so far, each as its arguments.
pub fn calls(&self) -> Vec<Vec<String>> {
let text = std::fs::read_to_string(self.dir.join("calls")).unwrap_or_default();
let mut calls = Vec::new();
let mut current = Vec::new();
for line in text.lines() {
if line == "---" {
calls.push(std::mem::take(&mut current));
} else {
current.push(line.to_string());
}
}
calls
}
/// What the last `run` read on standard input, if the body saved it to `$D/stdin`.
pub fn stdin(&self) -> String {
std::fs::read_to_string(self.dir.join("stdin")).unwrap_or_default()
}
/// A `[runner]` using this fake, with `extra` lines added.
pub fn runner(&self, extra: &str) -> Runner {
let text = format!(
"[runner]\npodman = \"{}\"\nimage = \"{IMAGE}\"\n{extra}\n",
self.script.display()
);
Config::parse(&text).unwrap().runner.unwrap()
}
}
/// A log that keeps its lines.
#[derive(Clone, Default)]
pub struct Lines(pub Arc<Mutex<Vec<String>>>);
impl Lines {
pub fn sink(&self) -> Arc<dyn Fn(&str) + Send + Sync> {
let lines = Arc::clone(&self.0);
Arc::new(move |l: &str| lines.lock().unwrap().push(l.to_string()))
}
pub fn all(&self) -> String {
self.0.lock().unwrap().join("\n")
}
}
pub fn path(p: &Path) -> String {
p.to_str().unwrap().to_string()
}