brokerd: start and remove the egress proxy for http_fetch
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
|
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
use std::process::{Child, Command, ExitStatus, Stdio};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -25,6 +26,10 @@ pub const UNEXPECTED: &str = "the tool failed with an unexpected status";
|
|||||||
pub const POLL: Duration = Duration::from_millis(50);
|
pub const POLL: Duration = Duration::from_millis(50);
|
||||||
/// How much of Podman's standard error is kept for the log.
|
/// How much of Podman's standard error is kept for the log.
|
||||||
pub const STDERR_KEPT: usize = 4096;
|
pub const STDERR_KEPT: usize = 4096;
|
||||||
|
/// How long `run` waits for the egress proxy to make its socket.
|
||||||
|
pub const EGRESS_WAIT: Duration = Duration::from_secs(5);
|
||||||
|
/// How often the egress proxy's socket is checked while waiting.
|
||||||
|
pub const EGRESS_POLL: Duration = Duration::from_millis(20);
|
||||||
|
|
||||||
/// A log sink. Call it as `(self.log)("a line")`.
|
/// A log sink. Call it as `(self.log)("a line")`.
|
||||||
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
|
pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
|
||||||
@@ -32,6 +37,7 @@ pub type Log = Arc<dyn Fn(&str) + Send + Sync>;
|
|||||||
pub struct Podman {
|
pub struct Podman {
|
||||||
runner: Runner,
|
runner: Runner,
|
||||||
egress_dir: PathBuf,
|
egress_dir: PathBuf,
|
||||||
|
egress_wait: Duration,
|
||||||
log: Log,
|
log: Log,
|
||||||
next: AtomicU64,
|
next: AtomicU64,
|
||||||
}
|
}
|
||||||
@@ -42,11 +48,20 @@ impl Podman {
|
|||||||
Podman {
|
Podman {
|
||||||
runner,
|
runner,
|
||||||
egress_dir,
|
egress_dir,
|
||||||
|
egress_wait: EGRESS_WAIT,
|
||||||
log,
|
log,
|
||||||
next: AtomicU64::new(0),
|
next: AtomicU64::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same runtime with another wait for the proxy's socket, for tests.
|
||||||
|
pub fn with_egress_wait(self, egress_wait: Duration) -> Podman {
|
||||||
|
Podman {
|
||||||
|
egress_wait,
|
||||||
|
..self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Where `http_fetch` calls get their directories. Public, so the field counts as read.
|
/// Where `http_fetch` calls get their directories. Public, so the field counts as read.
|
||||||
pub fn egress_dir(&self) -> &Path {
|
pub fn egress_dir(&self) -> &Path {
|
||||||
&self.egress_dir
|
&self.egress_dir
|
||||||
@@ -69,6 +84,26 @@ impl Podman {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The log for a `podman` that cannot be launched at all (task 11 step 3).
|
||||||
|
fn cannot_launch(&self, podman: &Path, e: std::io::Error) {
|
||||||
|
(self.log)(&format!(
|
||||||
|
"brokerd: cannot start {}: {}\n{}",
|
||||||
|
podman.display(),
|
||||||
|
e,
|
||||||
|
RUNBOOK
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The log for a directory that cannot be made (task 12 step 1).
|
||||||
|
fn cannot_make(&self, dir: &Path, e: std::io::Error) -> RunError {
|
||||||
|
(self.log)(&format!(
|
||||||
|
"brokerd: cannot make {}: {e}\n{}",
|
||||||
|
dir.display(),
|
||||||
|
RUNBOOK
|
||||||
|
));
|
||||||
|
RunError::Unavailable(CANNOT_START.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Wait for `child` until `limit` has passed since `started`. `Some(status)` if it ended;
|
/// 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>`,
|
/// `None` if it ran too long, after `podman kill <name>`, `podman rm -f <name>`,
|
||||||
/// `child.kill()` and `child.wait()` (step 5).
|
/// `child.kill()` and `child.wait()` (step 5).
|
||||||
@@ -129,12 +164,7 @@ impl Podman {
|
|||||||
{
|
{
|
||||||
Ok(child) => Some(child),
|
Ok(child) => Some(child),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
(self.log)(&format!(
|
self.cannot_launch(podman, e);
|
||||||
"brokerd: cannot start {}: {}\n{}",
|
|
||||||
podman.display(),
|
|
||||||
e,
|
|
||||||
RUNBOOK
|
|
||||||
));
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,14 +213,140 @@ impl Podman {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Runtime for Podman {
|
impl Runtime for Podman {
|
||||||
/// Steps 1 and 2, then `run_container`. Already written.
|
/// Step 1 (the container number), then step 2's choice: no egress runs the tool as before,
|
||||||
|
/// an egress starts the proxy first and gives the tool the directory. `run_container` is step 3
|
||||||
|
/// to 7 for either. Already written.
|
||||||
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
fn run(&self, spec: &RunSpec) -> Result<RunOutput, RunError> {
|
||||||
let n = self.next.fetch_add(1, Ordering::SeqCst);
|
let n = self.next.fetch_add(1, Ordering::SeqCst);
|
||||||
let name = podman::container_name(spec.session(), spec.call(), n);
|
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 input = spec.arguments().canonical_json();
|
||||||
let limit = self.runner.time_limit(spec.tool());
|
let limit = self.runner.time_limit(spec.tool());
|
||||||
self.run_container(&name, args, input, limit)
|
match spec.egress() {
|
||||||
|
None => {
|
||||||
|
let args = podman::tool_args(spec, &self.runner, &name, None);
|
||||||
|
self.run_container(&name, args, input, limit)
|
||||||
|
}
|
||||||
|
Some(hosts) => {
|
||||||
|
let dir = self.egress_dir.join(&name);
|
||||||
|
let _guard = self.start_egress(&name, &self.egress_dir, &dir, hosts)?;
|
||||||
|
let args = podman::tool_args(spec, &self.runner, &name, Some(&dir));
|
||||||
|
self.run_container(&name, args, input, limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Podman {
|
||||||
|
/// Steps 1 to 4 of the egress proxy's start (task 12). The guard it returns cleans up the
|
||||||
|
/// proxy and directory on every path; `run` keeps it alive until the tool's container ends.
|
||||||
|
fn start_egress(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
egress_dir: &Path,
|
||||||
|
dir: &Path,
|
||||||
|
hosts: &[String],
|
||||||
|
) -> Result<EgressGuard<'_>, RunError> {
|
||||||
|
// The guard is created before anything can fail, so every return below
|
||||||
|
// cleans up the proxy and directory by dropping it.
|
||||||
|
let _guard = EgressGuard::new(self, name, dir.to_path_buf());
|
||||||
|
// 1. Make the egress directory (and its parents) 0700, then replace the
|
||||||
|
// call's directory if a crash left one behind.
|
||||||
|
std::fs::DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.create(egress_dir)
|
||||||
|
.map_err(|e| self.cannot_make(dir, e))?;
|
||||||
|
let _ = std::fs::set_permissions(egress_dir, std::fs::Permissions::from_mode(0o700));
|
||||||
|
if dir.exists() {
|
||||||
|
match std::fs::remove_dir_all(dir) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => return Err(self.cannot_make(dir, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::DirBuilder::new()
|
||||||
|
.mode(0o700)
|
||||||
|
.create(dir)
|
||||||
|
.map_err(|e| self.cannot_make(dir, e))?;
|
||||||
|
|
||||||
|
// 2. Start the proxy, detached. A run that exits non-zero is logged and
|
||||||
|
// unavailable; one that cannot be launched at all uses task 11's log.
|
||||||
|
let args = podman::egress_args(&self.runner, name, dir, hosts);
|
||||||
|
match Command::new(&self.runner.podman)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(output) if output.status.success() => {}
|
||||||
|
Ok(output) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||||
|
(self.log)(&format!(
|
||||||
|
"brokerd: podman could not start {name}-egress: {stderr}\n{RUNBOOK}"
|
||||||
|
));
|
||||||
|
return Err(RunError::Unavailable(CANNOT_START.to_string()));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.cannot_launch(&self.runner.podman, e);
|
||||||
|
return Err(RunError::Unavailable(CANNOT_START.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Wait for the proxy to make its socket, or give up.
|
||||||
|
let socket = dir.join("egress.sock");
|
||||||
|
let deadline = Instant::now() + self.egress_wait;
|
||||||
|
loop {
|
||||||
|
if socket.exists() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
let ms = self.egress_wait.as_millis();
|
||||||
|
(self.log)(&format!(
|
||||||
|
"brokerd: {name}-egress did not make its socket within {ms} ms\n{RUNBOOK}"
|
||||||
|
));
|
||||||
|
return Err(RunError::Unavailable(CANNOT_START.to_string()));
|
||||||
|
}
|
||||||
|
std::thread::sleep(EGRESS_POLL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. The tool's container may now run; `run` holds the guard until it
|
||||||
|
// returns, then drops it to remove the proxy and directory.
|
||||||
|
Ok(_guard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the egress proxy's container and its directory when `run` returns, on every path.
|
||||||
|
///
|
||||||
|
/// `run` creates it before anything can fail and holds it until the tool's container has ended, so
|
||||||
|
/// the proxy and directory never survive the call that started it.
|
||||||
|
struct EgressGuard<'a> {
|
||||||
|
podman: &'a Podman,
|
||||||
|
name: String,
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Drop for EgressGuard<'a> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.podman.podman(&["rm", "-f", self.name.as_str()]);
|
||||||
|
match std::fs::remove_dir_all(&self.dir) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => (self.podman.log)(&format!(
|
||||||
|
"brokerd: cannot remove {}: {e}",
|
||||||
|
self.dir.display()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> EgressGuard<'a> {
|
||||||
|
fn new(podman: &'a Podman, name: &str, dir: PathBuf) -> EgressGuard<'a> {
|
||||||
|
EgressGuard {
|
||||||
|
podman,
|
||||||
|
name: format!("{name}-egress"),
|
||||||
|
dir,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
//! `http_fetch` through the Podman runtime: the proxy is started first, its socket awaited, the tool
|
||||||
|
//! run with the directory mounted, and the proxy and directory removed afterwards on every path
|
||||||
|
//! (M3b spec, section 6, "http_fetch"). Against a fake `podman`. Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/build.rs"]
|
||||||
|
mod build;
|
||||||
|
#[path = "support/fake_podman.rs"]
|
||||||
|
mod fake_podman;
|
||||||
|
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use brokerd::container::{CANNOT_START, Podman, RUNBOOK, TIMED_OUT};
|
||||||
|
use brokerd::policy::{Outcome, SessionState, decide};
|
||||||
|
use brokerd::runner::run;
|
||||||
|
use build::{fetch, grant, now, set};
|
||||||
|
use fake_podman::{Fake, Lines, path, serial};
|
||||||
|
use proto::{Mode, ToolResponse};
|
||||||
|
|
||||||
|
/// For `run -d` (the proxy): make the socket file in the mounted directory, as the proxy does.
|
||||||
|
const PROXY_OK: &str = r#"for a in "$@"; do case "$a" in --volume=*:/run/egress:rw) v=${a#--volume=}; v=${v%:/run/egress:rw};; esac; done"#;
|
||||||
|
|
||||||
|
fn body(proxy: &str, tool: &str) -> String {
|
||||||
|
format!("{PROXY_OK}\nif [ \"$2\" = -d ]; then\n{proxy}\nfi\n{tool}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(podman: &Podman) -> ToolResponse {
|
||||||
|
let grants = set(vec![
|
||||||
|
grant("web", "http_fetch", Mode::Auto).hosts(&["example.com", "*.example.org"]),
|
||||||
|
]);
|
||||||
|
let decision = match decide(
|
||||||
|
fetch("https://example.com/a"),
|
||||||
|
&grants,
|
||||||
|
SessionState::default(),
|
||||||
|
now(),
|
||||||
|
) {
|
||||||
|
Outcome::Allowed(d) => d,
|
||||||
|
other => panic!("not allowed: {other:?}"),
|
||||||
|
};
|
||||||
|
run(decision, podman)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed(message: &str) -> ToolResponse {
|
||||||
|
ToolResponse::Failed {
|
||||||
|
message: message.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_proxy_starts_first_the_tool_gets_its_socket_and_both_are_cleaned_up() {
|
||||||
|
let _s = serial();
|
||||||
|
let fake = Fake::new(
|
||||||
|
"eg-ok",
|
||||||
|
&body(
|
||||||
|
r#": > "$v/egress.sock"; exit 0"#,
|
||||||
|
r#"cat > "$D/stdin"; printf 'body\n[http 200]'; exit 0"#,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let log = Lines::default();
|
||||||
|
let egress = fake.dir.join("egress");
|
||||||
|
let got = call(&Podman::new(fake.runner(""), egress.clone(), log.sink()));
|
||||||
|
assert!(
|
||||||
|
matches!(&got, ToolResponse::Result { content, .. } if content == "body\n[http 200]"),
|
||||||
|
"{got:?} {}",
|
||||||
|
log.all()
|
||||||
|
);
|
||||||
|
let calls = fake.calls();
|
||||||
|
assert_eq!(calls.len(), 3, "{calls:?}");
|
||||||
|
let dir = egress.join("boxmaker-s1-1-0");
|
||||||
|
// 1. The proxy, detached, with the call's hosts.
|
||||||
|
assert_eq!(&calls[0][..2], ["run", "-d"]);
|
||||||
|
assert!(calls[0].contains(&"--name=boxmaker-s1-1-0-egress".to_string()));
|
||||||
|
assert!(calls[0].contains(&format!("--volume={}:/run/egress:rw", path(&dir))));
|
||||||
|
assert_eq!(calls[0].last().unwrap(), "example.com,*.example.org");
|
||||||
|
// 2. The tool, with the same directory and no network.
|
||||||
|
assert_eq!(&calls[1][..3], ["run", "--rm", "-i"]);
|
||||||
|
assert!(calls[1].contains(&format!("--volume={}:/run/egress:rw", path(&dir))));
|
||||||
|
assert!(calls[1].contains(&"--network=none".to_string()));
|
||||||
|
assert_eq!(fake.stdin(), r#"{"url":"https://example.com/a"}"#);
|
||||||
|
// 3. The proxy removed, and its directory with it.
|
||||||
|
assert_eq!(calls[2], ["rm", "-f", "boxmaker-s1-1-0-egress"]);
|
||||||
|
assert!(!dir.exists(), "the call's directory is removed");
|
||||||
|
let mode = std::fs::metadata(&egress).unwrap().permissions().mode() & 0o777;
|
||||||
|
assert_eq!(mode, 0o700);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_proxy_that_podman_cannot_start_means_no_tool_and_nothing_left() {
|
||||||
|
let _s = serial();
|
||||||
|
let fake = Fake::new(
|
||||||
|
"eg-fail",
|
||||||
|
&body(
|
||||||
|
"echo 'Error: network pasta not found' >&2; exit 125",
|
||||||
|
"cat > /dev/null; exit 0",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let log = Lines::default();
|
||||||
|
let egress = fake.dir.join("egress");
|
||||||
|
let got = call(&Podman::new(fake.runner(""), egress.clone(), log.sink()));
|
||||||
|
assert_eq!(got, failed(CANNOT_START));
|
||||||
|
let calls = fake.calls();
|
||||||
|
assert!(
|
||||||
|
calls
|
||||||
|
.iter()
|
||||||
|
.all(|c| c.get(2).map(String::as_str) != Some("-i")),
|
||||||
|
"no tool ran: {calls:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
calls.last().unwrap(),
|
||||||
|
&["rm", "-f", "boxmaker-s1-1-0-egress"]
|
||||||
|
);
|
||||||
|
assert!(!egress.join("boxmaker-s1-1-0").exists());
|
||||||
|
assert!(
|
||||||
|
log.all().contains("network pasta not found"),
|
||||||
|
"{}",
|
||||||
|
log.all()
|
||||||
|
);
|
||||||
|
assert!(log.all().contains(RUNBOOK));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_proxy_that_makes_no_socket_in_time_means_no_tool_and_nothing_left() {
|
||||||
|
let _s = serial();
|
||||||
|
let fake = Fake::new("eg-nosock", &body("exit 0", "cat > /dev/null; exit 0"));
|
||||||
|
let log = Lines::default();
|
||||||
|
let egress = fake.dir.join("egress");
|
||||||
|
let podman = Podman::new(fake.runner(""), egress.clone(), log.sink())
|
||||||
|
.with_egress_wait(Duration::from_millis(200));
|
||||||
|
let started = Instant::now();
|
||||||
|
assert_eq!(call(&podman), failed(CANNOT_START));
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(3));
|
||||||
|
let calls = fake.calls();
|
||||||
|
assert_eq!(calls.len(), 2, "{calls:?}");
|
||||||
|
assert_eq!(calls[1], ["rm", "-f", "boxmaker-s1-1-0-egress"]);
|
||||||
|
assert!(!egress.join("boxmaker-s1-1-0").exists());
|
||||||
|
assert!(log.all().contains(RUNBOOK));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tool_that_runs_too_long_still_leaves_nothing_behind() {
|
||||||
|
let _s = serial();
|
||||||
|
let fake = Fake::new(
|
||||||
|
"eg-slow",
|
||||||
|
&body(
|
||||||
|
r#": > "$v/egress.sock"; exit 0"#,
|
||||||
|
"cat > /dev/null; exec sleep 30",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let log = Lines::default();
|
||||||
|
let egress = fake.dir.join("egress");
|
||||||
|
let got = call(&Podman::new(
|
||||||
|
fake.runner("http_fetch_ms = 300"),
|
||||||
|
egress.clone(),
|
||||||
|
log.sink(),
|
||||||
|
));
|
||||||
|
assert_eq!(got, failed(TIMED_OUT));
|
||||||
|
let calls = fake.calls();
|
||||||
|
let tail: Vec<Vec<String>> = calls[2..].to_vec();
|
||||||
|
assert_eq!(
|
||||||
|
tail,
|
||||||
|
[
|
||||||
|
vec!["kill", "boxmaker-s1-1-0"],
|
||||||
|
vec!["rm", "-f", "boxmaker-s1-1-0"],
|
||||||
|
vec!["rm", "-f", "boxmaker-s1-1-0-egress"],
|
||||||
|
]
|
||||||
|
.map(|c| c.into_iter().map(String::from).collect::<Vec<_>>())
|
||||||
|
);
|
||||||
|
assert!(!egress.join("boxmaker-s1-1-0").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tool_that_podman_cannot_start_still_leaves_nothing_behind() {
|
||||||
|
let _s = serial();
|
||||||
|
let fake = Fake::new(
|
||||||
|
"eg-toolfail",
|
||||||
|
&body(
|
||||||
|
r#": > "$v/egress.sock"; exit 0"#,
|
||||||
|
"cat > /dev/null; exit 125",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let log = Lines::default();
|
||||||
|
let egress = fake.dir.join("egress");
|
||||||
|
assert_eq!(
|
||||||
|
call(&Podman::new(fake.runner(""), egress.clone(), log.sink())),
|
||||||
|
failed(CANNOT_START)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fake.calls().last().unwrap(),
|
||||||
|
&["rm", "-f", "boxmaker-s1-1-0-egress"]
|
||||||
|
);
|
||||||
|
assert!(!egress.join("boxmaker-s1-1-0").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_directory_left_by_a_crash_is_replaced() {
|
||||||
|
let _s = serial();
|
||||||
|
let fake = Fake::new(
|
||||||
|
"eg-stale",
|
||||||
|
&body(
|
||||||
|
r#"[ -e "$v/old.sock" ] && exit 9; : > "$v/egress.sock"; exit 0"#,
|
||||||
|
"cat > /dev/null; printf ok; exit 0",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let log = Lines::default();
|
||||||
|
let egress = fake.dir.join("egress");
|
||||||
|
std::fs::create_dir_all(egress.join("boxmaker-s1-1-0")).unwrap();
|
||||||
|
std::fs::write(egress.join("boxmaker-s1-1-0/old.sock"), "").unwrap();
|
||||||
|
let got = call(&Podman::new(fake.runner(""), egress.clone(), log.sink()));
|
||||||
|
assert!(
|
||||||
|
matches!(&got, ToolResponse::Result { content, .. } if content == "ok"),
|
||||||
|
"{got:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| M3b/12-brokerd-egress | 2026-09-23 | done | 1 | pass | none | Wrote `crates/brokerd/src/container.rs` (432 lines). Added consts `EGRESS_WAIT` (5s) and `EGRESS_POLL` (20ms); `Podman` gained private `egress_wait` (set to `EGRESS_WAIT` by `new`) and the public `with_egress_wait`. Added private `cannot_launch(podman, e)` (the task-11 step 3 log `brokerd: cannot start {path}: {e}\n{RUNBOOK}`), which `spawn` now calls, and `cannot_make(dir, e)` returning `Err(Unavailable(CANNOT_START))` with `brokerd: cannot make {dir}: {e}\n{RUNBOOK}`. `Runtime::run` now branches on `spec.egress()`: `None` runs `tool_args(spec, &runner, &name, None)` as before; `Some(hosts)` computes `dir = egress_dir.join(&name)`, calls `start_egress`, then `tool_args(spec, &runner, &name, Some(&dir))`. `start_egress` creates the `EgressGuard` first (so every return cleans up), then step 1: `DirBuilder::new().recursive(true).mode(0o700).create(egress_dir)`, `set_permissions(egress_dir, 0o700)` anyway, `remove_dir_all(dir)` if it exists (NotFound ok), `DirBuilder::new().mode(0o700).create(dir)` non-recursively, each failure via `cannot_make`; step 2: `Command::new(&runner.podman).args(egress_args).stdin(null).stdout(null).stderr(piped()).output()`, non-success logs `brokerd: podman could not start {name}-egress: {stderr}\n{RUNBOOK}` and returns Unavailable, launch error uses `cannot_launch`; step 3: wait for `dir.join("egress.sock")` every 20 ms until `egress_wait`, then log `brokerd: {name}-egress did not make its socket within {ms} ms\n{RUNBOOK}` and return Unavailable; step 4 `Ok(_guard)`. `EgressGuard<'a>` holds `&'a Podman`, the container name `<name>-egress` and the directory; its `Drop` runs `podman rm -f <name>-egress` (the task-11 helper) then `remove_dir_all(dir)` (NotFound ok, else log `brokerd: cannot remove {dir}: {e}`). Added `use std::os::unix::fs::{DirBuilderExt, PermissionsExt};`. `cargo fmt` put the new `impl Podman` block after `impl Runtime`. 6 egress + 11 container tests pass ten runs in a row. First gate failed on `start_egress` being inside `impl Runtime` (not a trait member) and missing `DirBuilderExt`/`PermissionsExt`; then on the guard being created at the end instead of the start (the two "nothing left" tests need the `rm -f <name>-egress` call on a failed proxy), fixed by moving `EgressGuard::new` to the top and returning it; then on `unused_variable` for the drop guard and `mismatched_lifetime_syntaxes` on the return type (fixed to `EgressGuard<'_>`), the guard renamed `_guard` (1.98 still lints drop-only bindings). `make gate` prints `gate: ok`. | ? |
|
||||||
| M3b/11-brokerd-container | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/container.rs`: the fixed-sentence constants (`COULD_NOT_RUN`, `CANNOT_START`, `KILLED`, `TIMED_OUT`, `UNEXPECTED`), `RUNBOOK`, `POLL`, `STDERR_KEPT` and the `Log` type; `Podman { runner, egress_dir, log, next: AtomicU64 }` with `new` (`next` starts at 0) and the public `egress_dir`. `Runtime::run` = `next.fetch_add` for the container number, `podman::container_name`, `podman::tool_args(spec, &runner, &name, None)`, `spec.arguments().canonical_json()`, `runner.time_limit(tool)`, then `run_container` (already written). `spawn` = `Command::new(&runner.podman)` with all three streams piped, on failure log `brokerd: cannot start {path}: {e}\n{RUNBOOK}` and return None. `Io::start` takes the three pipes and starts one thread each: write `input` then drop stdin, `read_capped` stdout with `cap`, `read_capped` stderr with `STDERR_KEPT`; `finish` joins all three (a missing or panicked thread counts as empty via `join().ok()`/`unwrap_or_default()`). `read_capped` reads past the cap with an 8 KiB buffer, keeping the first `cap` bytes and setting `truncated`, taking `remaining.min(n)` so it never indexes past what it kept. `wait` loops `child.try_wait()`, and once `limit` has elapsed runs `podman kill <name>` then `podman rm -f <name>` (each via the private `podman` helper — `.status()` with the three streams null, logging a line on non-success), then `child.kill()`/`child.wait()`, returning None. `podman` helper uses `&self.runner.podman`, not a `podman` field. `answer` follows the table: None→TIMED_OUT (log a line naming the container); exit 0/1→Ok with content `from_utf8_lossy(out)`; exit 2→COULD_NOT_RUN (log stderr); 125/126/127→CANNOT_START (log stderr then `\n{RUNBOOK}`); 137→KILLED; anything else incl. signal→UNEXPECTED (log status and stderr). The tool's output never enters a `RunError`; only the six constants do. Deleted the skeleton paragraph. Added `pub mod container;` to lib.rs after `config`. Copied `tests/support/fake_podman.rs` and `tests/container.rs`. `container` 11 passed ten runs; first gate failed on clippy `manual_range_patterns` (`125|126|127` → `125..=127`), fixed; `make gate` prints `gate: ok`. | ? |
|
| M3b/11-brokerd-container | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/container.rs`: the fixed-sentence constants (`COULD_NOT_RUN`, `CANNOT_START`, `KILLED`, `TIMED_OUT`, `UNEXPECTED`), `RUNBOOK`, `POLL`, `STDERR_KEPT` and the `Log` type; `Podman { runner, egress_dir, log, next: AtomicU64 }` with `new` (`next` starts at 0) and the public `egress_dir`. `Runtime::run` = `next.fetch_add` for the container number, `podman::container_name`, `podman::tool_args(spec, &runner, &name, None)`, `spec.arguments().canonical_json()`, `runner.time_limit(tool)`, then `run_container` (already written). `spawn` = `Command::new(&runner.podman)` with all three streams piped, on failure log `brokerd: cannot start {path}: {e}\n{RUNBOOK}` and return None. `Io::start` takes the three pipes and starts one thread each: write `input` then drop stdin, `read_capped` stdout with `cap`, `read_capped` stderr with `STDERR_KEPT`; `finish` joins all three (a missing or panicked thread counts as empty via `join().ok()`/`unwrap_or_default()`). `read_capped` reads past the cap with an 8 KiB buffer, keeping the first `cap` bytes and setting `truncated`, taking `remaining.min(n)` so it never indexes past what it kept. `wait` loops `child.try_wait()`, and once `limit` has elapsed runs `podman kill <name>` then `podman rm -f <name>` (each via the private `podman` helper — `.status()` with the three streams null, logging a line on non-success), then `child.kill()`/`child.wait()`, returning None. `podman` helper uses `&self.runner.podman`, not a `podman` field. `answer` follows the table: None→TIMED_OUT (log a line naming the container); exit 0/1→Ok with content `from_utf8_lossy(out)`; exit 2→COULD_NOT_RUN (log stderr); 125/126/127→CANNOT_START (log stderr then `\n{RUNBOOK}`); 137→KILLED; anything else incl. signal→UNEXPECTED (log status and stderr). The tool's output never enters a `RunError`; only the six constants do. Deleted the skeleton paragraph. Added `pub mod container;` to lib.rs after `config`. Copied `tests/support/fake_podman.rs` and `tests/container.rs`. `container` 11 passed ten runs; first gate failed on clippy `manual_range_patterns` (`125|126|127` → `125..=127`), fixed; `make gate` prints `gate: ok`. | ? |
|
||||||
| M3b/10-brokerd-podman-args | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/podman.rs`: the `EGRESS_MOUNT`/`EGRESS_SOCKET`/`TOOLKIT` constants; `container_name` = `boxmaker-<session>-<call>-<n>`; the private `hardening(pids, memory)` emitting the six shared flags (`--read-only` to `--memory=…`) once; the private `volume(host, container, mode)` built with `push` so a directory need not be UTF-8; `tool_args` = `run --rm -i --name=<name> --label=boxmaker=tool --network=none`, then hardening with `runner.pids`/`runner.memory`, `--tmpfs=/tmp:rw,size=64m,mode=1777`, one `--volume=<path>:<path>:ro|rw` per `spec.mounts()` in order, the egress volume when `egress` is Some, then `<image> /bin/toolkit <tool>`; `egress_args` = `run -d --rm --name=<name>-egress --label=boxmaker=egress --network=<egress_network>`, hardening 64/128m, the egress volume, then `<image> /bin/toolkit egress-proxy --socket /run/egress/egress.sock --allow <hosts joined with ','>`. `RunSpec` gained private `session: SessionId` and `call: CallId` as its first two fields with `session()`/`call()` getters; `run` fills them from `decision.request()`, and the `compile_fail` doctest struct literal gains the two fields first. Copied `tests/podman_args.rs` and the six `podman/*.args` golden files. `podman_args` 7 passed, `runner` 8 passed, all doctests pass. First gate failed on clippy `redundant_closure` (`.map(|f| OsString::from(f))` → `.map(OsString::from)`). `make gate` prints `gate: ok`. | ? |
|
| M3b/10-brokerd-podman-args | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/podman.rs`: the `EGRESS_MOUNT`/`EGRESS_SOCKET`/`TOOLKIT` constants; `container_name` = `boxmaker-<session>-<call>-<n>`; the private `hardening(pids, memory)` emitting the six shared flags (`--read-only` to `--memory=…`) once; the private `volume(host, container, mode)` built with `push` so a directory need not be UTF-8; `tool_args` = `run --rm -i --name=<name> --label=boxmaker=tool --network=none`, then hardening with `runner.pids`/`runner.memory`, `--tmpfs=/tmp:rw,size=64m,mode=1777`, one `--volume=<path>:<path>:ro|rw` per `spec.mounts()` in order, the egress volume when `egress` is Some, then `<image> /bin/toolkit <tool>`; `egress_args` = `run -d --rm --name=<name>-egress --label=boxmaker=egress --network=<egress_network>`, hardening 64/128m, the egress volume, then `<image> /bin/toolkit egress-proxy --socket /run/egress/egress.sock --allow <hosts joined with ','>`. `RunSpec` gained private `session: SessionId` and `call: CallId` as its first two fields with `session()`/`call()` getters; `run` fills them from `decision.request()`, and the `compile_fail` doctest struct literal gains the two fields first. Copied `tests/podman_args.rs` and the six `podman/*.args` golden files. `podman_args` 7 passed, `runner` 8 passed, all doctests pass. First gate failed on clippy `redundant_closure` (`.map(|f| OsString::from(f))` → `.map(OsString::from)`). `make gate` prints `gate: ok`. | ? |
|
||||||
| M3b/09-brokerd-runner-config | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/config.rs`: the `Runner` struct (`podman`, `image`, `egress_network`, `output_cap`, `memory`, `pids`, `read_file_ms`, `write_file_ms`, `shell_ms`, `http_fetch_ms`) with `#[serde(deny_unknown_fields)]` and one private `default_…()` per defaulted field; `image` is required with no default. `Config` gained `runner: Option<Runner>` with `#[serde(default)]`. `Runner::time_limit` maps each `ToolName` to its matching `_ms` field as a `Duration`. `Config::egress_dir()` joins `<home>/run/egress`. `load` runs, after the `ttl_ms` check and only when `runner` is `Some`, the four checks in order (first problem wins): image must be `<name>@sha256:<64 lowercase hex>` via `rsplit_once("@sha256:")` with a non-empty name and exactly 64 `0-9a-f`, memory must be digits then one of b/k/m/g (`valid_memory`), `egress_network`/`podman` non-empty, and the six non-negative fields checked for zero in order — each returns `ConfigError::Invalid`. `Config::parse` runs none of them. Copied `tests/config_runner.rs`, the five `runner_*.toml` fixtures, and the new `support/rig.rs`, which builds `Config` with `runner: None`. All brokerd tests pass; `config` and `config_runner` each 7 passed. First gate failed on clippy `incompatible_msrv`: `PathBuf::is_empty()` is stable since 1.98 but the MSRV is 1.95, fixed with `as_os_str().is_empty()` (the pattern the file already used for the socket paths). `make gate` prints `gate: ok`. | ? |
|
| M3b/09-brokerd-runner-config | 2026-09-23 | done | 2 | fail | none | Wrote `crates/brokerd/src/config.rs`: the `Runner` struct (`podman`, `image`, `egress_network`, `output_cap`, `memory`, `pids`, `read_file_ms`, `write_file_ms`, `shell_ms`, `http_fetch_ms`) with `#[serde(deny_unknown_fields)]` and one private `default_…()` per defaulted field; `image` is required with no default. `Config` gained `runner: Option<Runner>` with `#[serde(default)]`. `Runner::time_limit` maps each `ToolName` to its matching `_ms` field as a `Duration`. `Config::egress_dir()` joins `<home>/run/egress`. `load` runs, after the `ttl_ms` check and only when `runner` is `Some`, the four checks in order (first problem wins): image must be `<name>@sha256:<64 lowercase hex>` via `rsplit_once("@sha256:")` with a non-empty name and exactly 64 `0-9a-f`, memory must be digits then one of b/k/m/g (`valid_memory`), `egress_network`/`podman` non-empty, and the six non-negative fields checked for zero in order — each returns `ConfigError::Invalid`. `Config::parse` runs none of them. Copied `tests/config_runner.rs`, the five `runner_*.toml` fixtures, and the new `support/rig.rs`, which builds `Config` with `runner: None`. All brokerd tests pass; `config` and `config_runner` each 7 passed. First gate failed on clippy `incompatible_msrv`: `PathBuf::is_empty()` is stable since 1.98 but the MSRV is 1.95, fixed with `as_os_str().is_empty()` (the pattern the file already used for the socket paths). `make gate` prints `gate: ok`. | ? |
|
||||||
|
|||||||
Reference in New Issue
Block a user