use async_trait::async_trait;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::timeout;
use uuid::Uuid;
use crate::code_exec::CodeExecutor;
use crate::code_exec::types::{CodeExecutionInput, CodeExecutionResult};
use crate::core::InvocationContext;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct ContainerCodeExecutor {
pub image: String,
pub timeout: Duration,
pub argv: Vec<String>,
pub memory: String,
pub cpus: String,
pub pids_limit: u32,
pub user: String,
pub drop_capabilities: bool,
pub extra_args: Vec<String>,
}
impl Default for ContainerCodeExecutor {
fn default() -> Self {
Self {
image: "python:3.12-slim".into(),
timeout: Duration::from_secs(30),
argv: vec!["python3".into(), "-".into()],
memory: "256m".into(),
cpus: "1.0".into(),
pids_limit: 128,
user: "65534:65534".into(),
drop_capabilities: true,
extra_args: Vec::new(),
}
}
}
impl ContainerCodeExecutor {
#[must_use]
pub fn new(image: impl Into<String>) -> Self {
Self {
image: image.into(),
..Self::default()
}
}
#[must_use]
pub fn with_timeout(mut self, t: Duration) -> Self {
self.timeout = t;
self
}
#[must_use]
pub fn with_argv(mut self, argv: Vec<String>) -> Self {
self.argv = argv;
self
}
#[must_use]
pub fn with_memory(mut self, memory: impl Into<String>) -> Self {
self.memory = memory.into();
self
}
#[must_use]
pub fn with_cpus(mut self, cpus: impl Into<String>) -> Self {
self.cpus = cpus.into();
self
}
#[must_use]
pub fn with_pids_limit(mut self, pids_limit: u32) -> Self {
self.pids_limit = pids_limit;
self
}
#[must_use]
pub fn with_user(mut self, user: impl Into<String>) -> Self {
self.user = user.into();
self
}
#[must_use]
pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
self.extra_args = args;
self
}
pub fn build_run_args(&self, container_name: &str) -> Vec<String> {
let mut a: Vec<String> = vec![
"run".into(),
"--rm".into(),
"-i".into(),
"--network=none".into(),
"--read-only".into(),
"--tmpfs=/tmp:rw,exec,size=64m".into(),
format!("--memory={}", self.memory),
format!("--memory-swap={}", self.memory),
format!("--cpus={}", self.cpus),
format!("--pids-limit={}", self.pids_limit),
format!("--user={}", self.user),
];
if self.drop_capabilities {
a.push("--cap-drop=ALL".into());
a.push("--security-opt=no-new-privileges".into());
}
a.push("--name".into());
a.push(container_name.to_string());
a.extend(self.extra_args.iter().cloned());
a.push(self.image.clone());
a.extend(self.argv.iter().cloned());
a
}
}
async fn kill_container(name: &str) {
let _ = Command::new("docker").args(["kill", name]).output().await;
let _ = Command::new("docker")
.args(["rm", "-f", name])
.output()
.await;
}
#[async_trait]
impl CodeExecutor for ContainerCodeExecutor {
fn timeout(&self) -> Option<Duration> {
Some(self.timeout)
}
async fn execute_code(
&self,
_ctx: &InvocationContext,
input: CodeExecutionInput,
) -> Result<CodeExecutionResult> {
let container_name = format!("adk-rs-codex-{}", Uuid::new_v4());
let mut cmd = Command::new("docker");
cmd.args(self.build_run_args(&container_name))
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|e| Error::other(format!("docker run spawn: {e}")))?;
if let Some(mut stdin) = child.stdin.take() {
if let Err(e) = stdin.write_all(input.code.as_bytes()).await {
if e.kind() != std::io::ErrorKind::BrokenPipe {
kill_container(&container_name).await;
return Err(Error::other(format!("docker stdin: {e}")));
}
}
drop(stdin);
}
let wait = async {
child
.wait_with_output()
.await
.map_err(|e| Error::other(format!("docker wait: {e}")))
};
let output = match timeout(self.timeout, wait).await {
Ok(r) => r?,
Err(_) => {
kill_container(&container_name).await;
return Ok(CodeExecutionResult {
stdout: String::new(),
stderr: format!(
"container '{container_name}' execution timed out after {}s",
self.timeout.as_secs()
),
output_files: Vec::new(),
exit_code: None,
});
}
};
Ok(CodeExecutionResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
output_files: Vec::new(),
exit_code: output.status.code(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_args_include_full_lockdown() {
let ex = ContainerCodeExecutor::default();
let args = ex.build_run_args("name-1");
let joined = args.join(" ");
assert!(args.iter().any(|a| a == "--network=none"));
assert!(args.iter().any(|a| a == "--read-only"));
assert!(joined.contains("--memory=256m"));
assert!(joined.contains("--memory-swap=256m"));
assert!(joined.contains("--cpus=1.0"));
assert!(joined.contains("--pids-limit=128"));
assert!(args.iter().any(|a| a == "--cap-drop=ALL"));
assert!(args.iter().any(|a| a == "--security-opt=no-new-privileges"));
assert!(joined.contains("--user=65534:65534"));
let name_idx = args.iter().position(|a| a == "--name").unwrap();
assert_eq!(args[name_idx + 1], "name-1");
let image_idx = args.iter().position(|a| a == "python:3.12-slim").unwrap();
assert!(image_idx > name_idx, "image must follow --name");
assert_eq!(args.last().unwrap(), "-");
}
#[test]
fn overrides_take_effect() {
let ex = ContainerCodeExecutor::new("alpine")
.with_memory("64m")
.with_cpus("0.25")
.with_pids_limit(32)
.with_user("1000:1000");
let args = ex.build_run_args("n").join(" ");
assert!(args.contains("--memory=64m"));
assert!(args.contains("--memory-swap=64m"));
assert!(args.contains("--cpus=0.25"));
assert!(args.contains("--pids-limit=32"));
assert!(args.contains("--user=1000:1000"));
}
#[test]
fn drop_capabilities_off_explicitly_omits_them() {
let mut ex = ContainerCodeExecutor::default();
ex.drop_capabilities = false;
let args = ex.build_run_args("n");
assert!(!args.iter().any(|a| a == "--cap-drop=ALL"));
assert!(!args.iter().any(|a| a == "--security-opt=no-new-privileges"));
}
}