use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::process::Command;
use tokio::sync::Mutex;
use tracing::warn;
use super::{
AgentRuntime, AgentSpec, EnvAllowlist, Recovered, RuntimeError, RuntimeHealth, RuntimeStatus,
tail_lines,
};
use crate::core::{AgentConfig, StorageConfig};
use crate::registry::AgentId;
const DEFAULT_IMAGE: &str = "a2a-agents:latest";
const CONTAINER_CONFIG_PATH: &str = "/etc/agent.toml";
const CONFIG_PATH_ENV: &str = "A2A_CONFIG";
const LABEL_AGENT: &str = "a2a-agent";
const LABEL_PORT: &str = "a2a-port";
#[derive(Clone)]
pub struct ContainerRuntime {
engine: String,
base_image: String,
allowed_env: EnvAllowlist,
hardening: ContainerHardening,
agents: Arc<Mutex<HashMap<AgentId, u16>>>,
}
impl ContainerRuntime {
pub fn new() -> Self {
Self::with_engine("docker")
}
pub fn with_engine(engine: impl Into<String>) -> Self {
Self {
engine: engine.into(),
base_image: DEFAULT_IMAGE.to_string(),
allowed_env: EnvAllowlist::deny_all(),
hardening: ContainerHardening::default(),
agents: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn with_hardening(mut self, hardening: ContainerHardening) -> Self {
self.hardening = hardening;
self
}
pub fn with_base_image(mut self, image: impl Into<String>) -> Self {
self.base_image = image.into();
self
}
pub fn with_allowed_env(mut self, allowed: EnvAllowlist) -> Self {
self.allowed_env = allowed;
self
}
async fn inspect_status(&self, name: &str) -> Option<String> {
let args = [
"inspect".to_string(),
"-f".to_string(),
"{{.State.Status}}".to_string(),
name.to_string(),
];
run_engine(&self.engine, &args).await.ok()
}
}
impl Default for ContainerRuntime {
fn default() -> Self {
Self::new()
}
}
const CONTAINER_TMPFS: &str = "/tmp";
const DEFAULT_PIDS_LIMIT: u32 = 512;
fn container_name(id: &AgentId) -> String {
format!("a2a-agent-{id}")
}
#[derive(Debug, Clone)]
pub struct ContainerHardening {
pub drop_capabilities: bool,
pub no_new_privileges: bool,
pub read_only_rootfs: bool,
pub pids_limit: Option<u32>,
pub memory: Option<String>,
pub cpus: Option<String>,
}
impl Default for ContainerHardening {
fn default() -> Self {
Self {
drop_capabilities: true,
no_new_privileges: true,
read_only_rootfs: true,
pids_limit: Some(DEFAULT_PIDS_LIMIT),
memory: None,
cpus: None,
}
}
}
impl ContainerHardening {
pub fn none() -> Self {
Self {
drop_capabilities: false,
no_new_privileges: false,
read_only_rootfs: false,
pids_limit: None,
memory: None,
cpus: None,
}
}
pub fn with_memory(mut self, memory: impl Into<String>) -> Self {
self.memory = Some(memory.into());
self
}
pub fn with_cpus(mut self, cpus: impl Into<String>) -> Self {
self.cpus = Some(cpus.into());
self
}
fn args(&self, writable_rootfs_needed: bool) -> Vec<String> {
let mut args = Vec::new();
if self.drop_capabilities {
args.push("--cap-drop=ALL".to_string());
}
if self.no_new_privileges {
args.push("--security-opt=no-new-privileges".to_string());
}
if self.read_only_rootfs && !writable_rootfs_needed {
args.push("--read-only".to_string());
args.push("--tmpfs".to_string());
args.push(CONTAINER_TMPFS.to_string());
}
if let Some(pids) = self.pids_limit {
args.push("--pids-limit".to_string());
args.push(pids.to_string());
}
if let Some(memory) = &self.memory {
args.push("--memory".to_string());
args.push(memory.clone());
}
if let Some(cpus) = &self.cpus {
args.push("--cpus".to_string());
args.push(cpus.clone());
}
args
}
}
fn needs_writable_rootfs(config: &AgentConfig) -> bool {
!matches!(config.server.storage, StorageConfig::InMemory)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AgentImage<'a> {
Base(&'a str),
Own(&'a str),
}
impl<'a> AgentImage<'a> {
fn resolve(spec_image: Option<&'a str>, base: &'a str) -> Self {
match spec_image {
Some(image) => AgentImage::Own(image),
None => AgentImage::Base(base),
}
}
fn reference(&self) -> &'a str {
match self {
AgentImage::Base(image) | AgentImage::Own(image) => image,
}
}
fn command(&self) -> Vec<String> {
match self {
AgentImage::Base(_) => vec![
"run".to_string(),
"--config".to_string(),
CONTAINER_CONFIG_PATH.to_string(),
],
AgentImage::Own(_) => Vec::new(),
}
}
}
struct CreateParams<'a> {
image: AgentImage<'a>,
id: &'a AgentId,
port: u16,
config_path: &'a Path,
env_refs: &'a [String],
hardening: &'a ContainerHardening,
writable_rootfs_needed: bool,
}
fn create_args(params: CreateParams<'_>) -> Vec<String> {
let CreateParams {
image,
id,
port,
config_path,
env_refs,
hardening,
writable_rootfs_needed,
} = params;
let mut args = vec![
"create".to_string(),
"--name".to_string(),
container_name(id),
"-p".to_string(),
format!("{port}:{port}"),
"-e".to_string(),
"HOST=0.0.0.0".to_string(),
"-e".to_string(),
format!("{CONFIG_PATH_ENV}={CONTAINER_CONFIG_PATH}"),
];
for var in env_refs {
args.push("-e".to_string());
args.push(var.clone());
}
args.extend(hardening.args(writable_rootfs_needed));
args.extend([
"-v".to_string(),
format!("{}:{CONTAINER_CONFIG_PATH}:ro", config_path.display()),
"--label".to_string(),
format!("{LABEL_AGENT}={id}"),
"--label".to_string(),
format!("{LABEL_PORT}={port}"),
image.reference().to_string(),
]);
args.extend(image.command());
args
}
fn label_template(label: &str) -> String {
format!("{{{{.Label \"{label}\"}}}}")
}
fn parse_labelled_containers(output: &str) -> Vec<(AgentId, u16)> {
output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter_map(|line| match line.split_once('\t') {
Some((id, port)) if !id.is_empty() => match port.trim().parse::<u16>() {
Ok(port) => Some((AgentId::from(id.trim()), port)),
Err(_) => {
warn!("skipping container '{id}': unreadable {LABEL_PORT} label '{port}'");
None
}
},
_ => {
warn!("skipping container: unexpected `ps` line '{line}'");
None
}
})
.collect()
}
fn logs_args(id: &AgentId, tail: Option<usize>) -> Vec<String> {
let mut args = vec!["logs".to_string(), "--timestamps".to_string()];
if let Some(n) = tail {
args.push("--tail".to_string());
args.push(n.to_string());
}
args.push(container_name(id));
args
}
async fn run_engine_combined(engine: &str, args: &[String]) -> Result<String, RuntimeError> {
let output = Command::new(engine)
.args(args)
.output()
.await
.map_err(|e| RuntimeError::Backend(format!("could not run `{engine}`: {e}")))?;
if !output.status.success() {
let verb = args.first().map(String::as_str).unwrap_or("");
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RuntimeError::Backend(format!(
"`{engine} {verb}` failed: {}",
stderr.trim()
)));
}
let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
combined.push_str(&String::from_utf8_lossy(&output.stderr));
Ok(combined)
}
async fn run_engine(engine: &str, args: &[String]) -> Result<String, RuntimeError> {
let output = Command::new(engine)
.args(args)
.output()
.await
.map_err(|e| RuntimeError::Backend(format!("could not run `{engine}`: {e}")))?;
if !output.status.success() {
let verb = args.first().map(String::as_str).unwrap_or("");
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RuntimeError::Backend(format!(
"`{engine} {verb}` failed: {}",
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[async_trait]
impl AgentRuntime for ContainerRuntime {
async fn provision(&self, spec: AgentSpec) -> Result<AgentId, RuntimeError> {
let id = spec.id.clone();
let content = tokio::fs::read_to_string(&spec.config_path)
.await
.map_err(|e| RuntimeError::Config(e.to_string()))?;
let env_refs = self.allowed_env.check(&content)?;
let config =
AgentConfig::from_toml(&content).map_err(|e| RuntimeError::Config(e.to_string()))?;
let port = config.server.http_port;
let _ = run_engine(
&self.engine,
&["rm".to_string(), "-f".to_string(), container_name(&id)],
)
.await;
run_engine(
&self.engine,
&create_args(CreateParams {
image: AgentImage::resolve(spec.image.as_deref(), &self.base_image),
id: &id,
port,
config_path: &spec.config_path,
env_refs: &env_refs,
hardening: &self.hardening,
writable_rootfs_needed: needs_writable_rootfs(&config),
}),
)
.await?;
self.agents.lock().await.insert(id.clone(), port);
Ok(id)
}
async fn recover(&self) -> Result<Recovered<AgentId>, RuntimeError> {
let output = run_engine(
&self.engine,
&[
"ps".to_string(),
"-a".to_string(),
"--filter".to_string(),
format!("label={LABEL_AGENT}"),
"--format".to_string(),
format!(
"{}\t{}",
label_template(LABEL_AGENT),
label_template(LABEL_PORT)
),
],
)
.await?;
let found = parse_labelled_containers(&output);
let mut agents = self.agents.lock().await;
let mut adopted = Vec::with_capacity(found.len());
for (id, port) in found {
agents.insert(id.clone(), port);
adopted.push(id);
}
adopted.sort_by(|a, b| a.as_str().cmp(b.as_str()));
Ok(Recovered::Adopted(adopted))
}
async fn start(&self, id: &AgentId) -> Result<(), RuntimeError> {
let name = {
let guard = self.agents.lock().await;
if !guard.contains_key(id) {
return Err(RuntimeError::NotFound(id.clone()));
}
container_name(id)
};
if self.inspect_status(&name).await.as_deref() == Some("running") {
return Err(RuntimeError::AlreadyRunning(id.clone()));
}
run_engine(&self.engine, &["start".to_string(), name]).await?;
Ok(())
}
async fn stop(&self, id: &AgentId) -> Result<(), RuntimeError> {
let name = {
let guard = self.agents.lock().await;
if !guard.contains_key(id) {
return Err(RuntimeError::NotFound(id.clone()));
}
container_name(id)
};
run_engine(&self.engine, &["stop".to_string(), name]).await?;
Ok(())
}
async fn health(&self, id: &AgentId) -> Result<RuntimeHealth, RuntimeError> {
let (name, port) = {
let guard = self.agents.lock().await;
let port = *guard
.get(id)
.ok_or_else(|| RuntimeError::NotFound(id.clone()))?;
(container_name(id), port)
};
match self.inspect_status(&name).await.as_deref() {
Some("created") => Ok(RuntimeHealth::Provisioned),
Some("running") => {
match a2a_rs::fetch_agent_card(&format!("http://127.0.0.1:{port}")).await {
Ok(_) => Ok(RuntimeHealth::Healthy),
Err(_) => Ok(RuntimeHealth::Unhealthy),
}
}
_ => Ok(RuntimeHealth::Stopped),
}
}
async fn list(&self) -> Result<Vec<RuntimeStatus>, RuntimeError> {
let ids: Vec<(AgentId, u16)> = {
let guard = self.agents.lock().await;
guard.iter().map(|(id, port)| (id.clone(), *port)).collect()
};
let mut statuses = Vec::with_capacity(ids.len());
for (id, port) in ids {
let health = self.health(&id).await?;
statuses.push(RuntimeStatus {
id,
health,
endpoint: format!("http://127.0.0.1:{port}"),
});
}
Ok(statuses)
}
async fn logs(&self, id: &AgentId, tail: Option<usize>) -> Result<Vec<String>, RuntimeError> {
if !self.agents.lock().await.contains_key(id) {
return Err(RuntimeError::NotFound(id.clone()));
}
let output = run_engine_combined(&self.engine, &logs_args(id, tail)).await?;
Ok(tail_lines(&output, None))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn container_name_is_prefixed_slug() {
assert_eq!(
container_name(&AgentId::from_name("Weather Agent")),
"a2a-agent-weather-agent"
);
}
fn args_for(
id: &AgentId,
port: u16,
env_refs: &[String],
hardening: &ContainerHardening,
writable_rootfs_needed: bool,
) -> Vec<String> {
image_args_for(
AgentImage::Base("a2a-agents:latest"),
id,
port,
env_refs,
hardening,
writable_rootfs_needed,
)
}
fn image_args_for(
image: AgentImage<'_>,
id: &AgentId,
port: u16,
env_refs: &[String],
hardening: &ContainerHardening,
writable_rootfs_needed: bool,
) -> Vec<String> {
create_args(CreateParams {
image,
id,
port,
config_path: Path::new("/cfg/echo.toml"),
env_refs,
hardening,
writable_rootfs_needed,
})
}
#[test]
fn create_args_build_expected_argv() {
let id = AgentId::from_name("Echo Agent");
let args = args_for(&id, 8080, &[], &ContainerHardening::none(), false);
assert_eq!(
args,
vec![
"create",
"--name",
"a2a-agent-echo-agent",
"-p",
"8080:8080",
"-e",
"HOST=0.0.0.0",
"-e",
"A2A_CONFIG=/etc/agent.toml",
"-v",
"/cfg/echo.toml:/etc/agent.toml:ro",
"--label",
"a2a-agent=echo-agent",
"--label",
"a2a-port=8080",
"a2a-agents:latest",
"run",
"--config",
"/etc/agent.toml",
]
);
}
#[test]
fn an_agents_own_image_is_started_without_a_command() {
let id = AgentId::from_name("Billing Agent");
let args = image_args_for(
AgentImage::Own("ghcr.io/acme/billing:1.4"),
&id,
8080,
&[],
&ContainerHardening::none(),
false,
);
assert_eq!(
args.last().map(String::as_str),
Some("ghcr.io/acme/billing:1.4")
);
assert!(
!args.iter().any(|a| a == "run" || a == "--config"),
"someone else's image must boot by its own ENTRYPOINT: {args:?}"
);
}
#[test]
fn every_image_gets_the_same_config_contract() {
let id = AgentId::from_name("Billing Agent");
let hardening = ContainerHardening::none();
for image in [
AgentImage::Base("a2a-agents:latest"),
AgentImage::Own("ghcr.io/acme/billing:1.4"),
] {
let args = image_args_for(image, &id, 8080, &[], &hardening, false);
assert!(
args.iter().any(|a| a == "A2A_CONFIG=/etc/agent.toml"),
"{image:?} must be told where its config is: {args:?}"
);
assert!(
args.iter()
.any(|a| a == "/cfg/echo.toml:/etc/agent.toml:ro"),
"{image:?} must have its config mounted: {args:?}"
);
assert!(
args.iter().any(|a| a == "HOST=0.0.0.0"),
"{image:?} must be told to bind all interfaces: {args:?}"
);
}
}
#[test]
fn a_spec_image_overrides_the_runtimes_base() {
assert_eq!(
AgentImage::resolve(Some("ghcr.io/acme/billing:1.4"), "a2a-agents:latest"),
AgentImage::Own("ghcr.io/acme/billing:1.4")
);
assert_eq!(
AgentImage::resolve(None, "a2a-agents:latest"),
AgentImage::Base("a2a-agents:latest")
);
}
#[test]
fn recovery_reads_back_what_create_stamped() {
let id = AgentId::from_name("Weather Agent");
let args = args_for(&id, 9100, &[], &ContainerHardening::default(), false);
let labels: Vec<&String> = args
.iter()
.enumerate()
.filter(|(i, _)| i > &0 && args[i - 1] == "--label")
.map(|(_, value)| value)
.collect();
let agent_label = labels
.iter()
.find_map(|l| l.strip_prefix(&format!("{LABEL_AGENT}=")))
.expect("agent label");
let port_label = labels
.iter()
.find_map(|l| l.strip_prefix(&format!("{LABEL_PORT}=")))
.expect("port label");
assert_eq!(
parse_labelled_containers(&format!("{agent_label}\t{port_label}")),
[(id, 9100)]
);
}
#[test]
fn parsing_skips_unusable_lines_but_keeps_the_rest() {
let output = "\
weather-agent\t8080
billing-agent\tnot-a-port
\t9000
orphan-container
billing-agent\t8081
";
assert_eq!(
parse_labelled_containers(output),
[
(AgentId::from("weather-agent"), 8080),
(AgentId::from("billing-agent"), 8081),
]
);
}
#[test]
fn label_template_is_a_go_template_for_one_label() {
assert_eq!(label_template("a2a-agent"), r#"{{.Label "a2a-agent"}}"#);
}
#[test]
fn logs_args_target_the_agents_container() {
let id = AgentId::from_name("Weather Agent");
assert_eq!(
logs_args(&id, None),
["logs", "--timestamps", "a2a-agent-weather-agent"]
);
assert_eq!(
logs_args(&id, Some(50)),
[
"logs",
"--timestamps",
"--tail",
"50",
"a2a-agent-weather-agent"
]
);
}
#[test]
fn create_args_pass_env_refs_through_by_name_only() {
let id = AgentId::from_name("LLM Agent");
let args = args_for(
&id,
8080,
&["API_TOKEN".to_string(), "OPENROUTER_API_KEY".to_string()],
&ContainerHardening::default(),
false,
);
let owned = args
.iter()
.position(|a| a == "A2A_CONFIG=/etc/agent.toml")
.unwrap();
assert_eq!(
&args[owned + 1..owned + 5],
["-e", "API_TOKEN", "-e", "OPENROUTER_API_KEY"]
);
assert!(!args.iter().any(|a| a.contains('=') && a.contains("TOKEN")));
}
#[test]
fn default_hardening_drops_privileges_and_caps_processes() {
let args = args_for(
&AgentId::from_name("Echo Agent"),
8080,
&[],
&ContainerHardening::default(),
false,
);
for expected in ["--cap-drop=ALL", "--security-opt=no-new-privileges"] {
assert!(args.iter().any(|a| a == expected), "missing {expected}");
}
let pids = args.iter().position(|a| a == "--pids-limit").expect("pids");
assert_eq!(args[pids + 1], DEFAULT_PIDS_LIMIT.to_string());
assert!(!args.iter().any(|a| a == "--memory" || a == "--cpus"));
}
#[test]
fn resource_ceilings_are_opt_in() {
let hardening = ContainerHardening::default()
.with_memory("512m")
.with_cpus("1.5");
let args = args_for(&AgentId::from("a"), 8080, &[], &hardening, false);
let memory = args.iter().position(|a| a == "--memory").expect("memory");
assert_eq!(args[memory + 1], "512m");
let cpus = args.iter().position(|a| a == "--cpus").expect("cpus");
assert_eq!(args[cpus + 1], "1.5");
}
#[test]
fn read_only_rootfs_follows_what_the_agent_stores() {
let hardening = ContainerHardening::default();
let in_memory = args_for(&AgentId::from("a"), 8080, &[], &hardening, false);
assert!(in_memory.iter().any(|a| a == "--read-only"));
let tmpfs = in_memory
.iter()
.position(|a| a == "--tmpfs")
.expect("tmpfs");
assert_eq!(in_memory[tmpfs + 1], CONTAINER_TMPFS);
let writes = args_for(&AgentId::from("a"), 8080, &[], &hardening, true);
assert!(!writes.iter().any(|a| a == "--read-only" || a == "--tmpfs"));
assert!(writes.iter().any(|a| a == "--cap-drop=ALL"));
}
#[test]
fn hardening_can_be_turned_off_entirely() {
let args = args_for(
&AgentId::from("a"),
8080,
&[],
&ContainerHardening::none(),
false,
);
for flag in [
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--read-only",
"--pids-limit",
] {
assert!(!args.iter().any(|a| a == flag), "{flag} should be absent");
}
}
#[test]
fn only_in_memory_storage_is_known_not_to_write() {
let config = |storage: &str| {
AgentConfig::from_toml(&format!(
r#"
[agent]
name = "Store"
[handler]
type = "echo"
[server]
http_port = 8080
{storage}
"#
))
.expect("parse")
};
assert!(!needs_writable_rootfs(&config("")));
assert!(!needs_writable_rootfs(&config(
"\n[server.storage]\ntype = \"inmemory\""
)));
assert!(needs_writable_rootfs(&config(
"\n[server.storage]\ntype = \"sqlx\"\nurl = \"sqlite:agent.db\""
)));
}
}