a2a_agents/runtime/container.rs
1//! [`ContainerRuntime`] — run each agent in a Docker/Podman container.
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use tokio::process::Command;
9use tokio::sync::Mutex;
10use tracing::warn;
11
12use super::{
13 AgentRuntime, AgentSpec, EnvAllowlist, Recovered, RuntimeError, RuntimeHealth, RuntimeStatus,
14 tail_lines,
15};
16use crate::core::{AgentConfig, StorageConfig};
17use crate::registry::AgentId;
18
19/// Default base image, for agents that do not name one of their own. Override
20/// with [`ContainerRuntime::with_base_image`].
21const DEFAULT_IMAGE: &str = "a2a-agents:latest";
22
23/// Where the agent's TOML is mounted inside the container.
24const CONTAINER_CONFIG_PATH: &str = "/etc/agent.toml";
25
26/// Environment variable naming the mounted config, so an agent's own image can
27/// find it without hard-coding [`CONTAINER_CONFIG_PATH`].
28const CONFIG_PATH_ENV: &str = "A2A_CONFIG";
29
30/// Label carrying the [`AgentId`]. `<engine> ps --filter label=a2a-agent` is
31/// this adapter's durable store: it is what makes the engine, not this process's
32/// memory, the source of truth about which agents exist.
33const LABEL_AGENT: &str = "a2a-agent";
34
35/// Label carrying the published host port, so recovery reconstructs the whole
36/// in-memory map from the engine rather than half of it. Parsing it back out of
37/// `{{.Ports}}` would mean parsing `0.0.0.0:8080->8080/tcp, :::8080->8080/tcp`;
38/// stamping it is exact.
39const LABEL_PORT: &str = "a2a-port";
40
41/// An [`AgentRuntime`] that runs each agent in its own container via a
42/// `docker`/`podman` CLI (shelled out through [`tokio::process`], so no Docker
43/// API dependency — the engine binary is the only requirement).
44///
45/// One container per agent, named `a2a-agent-<id>`. The engine is the source of
46/// truth for liveness (`inspect`); the in-memory map only remembers each agent's
47/// published port so health can probe its card.
48///
49/// **Images:** an agent runs from the base image
50/// ([`with_base_image`](Self::with_base_image)) unless its config names its own
51/// with `[runtime] image`, which is how an agent whose handler no TOML can
52/// express gets deployed like any other. Either way the container gets the same
53/// contract — the config bind-mounted at `/etc/agent.toml` and named by
54/// `A2A_CONFIG`, `HOST=0.0.0.0`, the configured port published, and the
55/// allowlisted variables passed through — but only the base image is given a
56/// command (`a2a run --config …`). An agent's own image boots by its own
57/// `ENTRYPOINT`.
58///
59/// **Binding:** the in-container agent must bind `0.0.0.0` to be reachable
60/// through the published port. The base image sets `HOST=0.0.0.0` and this
61/// adapter passes `-e HOST=0.0.0.0`; since the config's `default_host` reads
62/// `HOST`, a config that **omits** `host` binds all interfaces. A config that
63/// hard-codes `host = "127.0.0.1"` will not be reachable.
64///
65/// **Secrets:** keep them out of the TOML as `${VAR}` refs. Each referenced
66/// variable — provided it is on the [`EnvAllowlist`] — is injected into the
67/// container as a value-less `-e VAR` pass-through, resolved from *this*
68/// process's environment by the engine CLI. So the bind-mounted TOML and the
69/// `docker create` argv never carry secret values, and the in-container
70/// `a2a run` expands the refs at startup. The deploying process (e.g.
71/// `a2a control-plane`) must therefore hold the secrets in its own env;
72/// provisioning fails fast if a ref has no value and no `${VAR:-default}`.
73///
74/// The allowlist is **deny-by-default** ([`with_allowed_env`](Self::with_allowed_env)):
75/// without it, any accepted config could name any variable this process holds
76/// and have it expanded into the agent's card for anyone to read back.
77///
78/// **Hardening:** every agent is created with capabilities dropped, privilege
79/// escalation blocked, a process cap, and — where its storage permits — a
80/// read-only root filesystem. See [`ContainerHardening`] for what is on by
81/// default and why resource ceilings are not. Two consequences worth knowing:
82/// an agent cannot bind a port below 1024 (the image runs as a non-root user
83/// anyway), and a handler that writes outside `/tmp` needs either `sqlx`
84/// storage or a relaxed policy.
85///
86/// **Platform:** the config is bind-mounted (`-v host:container`), so host config
87/// paths must be expressible as a Docker volume source — works on Linux/macOS;
88/// Windows host paths need conversion (out of scope here).
89#[derive(Clone)]
90pub struct ContainerRuntime {
91 engine: String,
92 /// Image for agents that do not name one of their own.
93 base_image: String,
94 /// Which host env vars a deployed config may reference. Deny-all by default.
95 allowed_env: EnvAllowlist,
96 /// What the engine is asked to enforce on each agent.
97 hardening: ContainerHardening,
98 /// id -> published host port (presence == provisioned).
99 agents: Arc<Mutex<HashMap<AgentId, u16>>>,
100}
101
102impl ContainerRuntime {
103 /// Use `docker` with the default base image.
104 pub fn new() -> Self {
105 Self::with_engine("docker")
106 }
107
108 /// Use a specific engine binary (`"docker"` or `"podman"`).
109 pub fn with_engine(engine: impl Into<String>) -> Self {
110 Self {
111 engine: engine.into(),
112 base_image: DEFAULT_IMAGE.to_string(),
113 allowed_env: EnvAllowlist::deny_all(),
114 hardening: ContainerHardening::default(),
115 agents: Arc::new(Mutex::new(HashMap::new())),
116 }
117 }
118
119 /// Replace the hardening policy applied to every agent this runtime
120 /// provisions (default: [`ContainerHardening::default`]).
121 pub fn with_hardening(mut self, hardening: ContainerHardening) -> Self {
122 self.hardening = hardening;
123 self
124 }
125
126 /// Override the base image (default [`DEFAULT_IMAGE`]).
127 ///
128 /// This is the image for agents that do not name one. An agent whose config
129 /// sets `[runtime] image` runs from that instead, whatever this is set to.
130 pub fn with_base_image(mut self, image: impl Into<String>) -> Self {
131 self.base_image = image.into();
132 self
133 }
134
135 /// Permit deployed configs to reference these host environment variables.
136 /// Anything not listed is rejected at [`provision`](AgentRuntime::provision).
137 pub fn with_allowed_env(mut self, allowed: EnvAllowlist) -> Self {
138 self.allowed_env = allowed;
139 self
140 }
141
142 /// `inspect -f {{.State.Status}}` → the container's status, or `None` when no
143 /// such container exists.
144 async fn inspect_status(&self, name: &str) -> Option<String> {
145 let args = [
146 "inspect".to_string(),
147 "-f".to_string(),
148 "{{.State.Status}}".to_string(),
149 name.to_string(),
150 ];
151 run_engine(&self.engine, &args).await.ok()
152 }
153}
154
155impl Default for ContainerRuntime {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161/// Where a read-only agent gets scratch space, since a read-only root
162/// filesystem otherwise leaves nothing writable at all.
163const CONTAINER_TMPFS: &str = "/tmp";
164
165/// Default process cap. Nothing an agent legitimately does comes near this —
166/// even one spawning an `mcp_client` child per configured server — while a
167/// runaway loop or a fork bomb hits it immediately.
168const DEFAULT_PIDS_LIMIT: u32 = 512;
169
170/// The container name for an agent: `a2a-agent-<id>`.
171fn container_name(id: &AgentId) -> String {
172 format!("a2a-agent-{id}")
173}
174
175/// What [`ContainerRuntime`] asks the engine to enforce on each agent.
176///
177/// This is the cheap 80% of isolation: a handful of `create` flags that remove
178/// the capabilities an HTTP server never needs and bound what a misbehaving one
179/// can consume. It is *not* a sandbox against code written to escape — that
180/// would be a microVM, and it is deliberately not what this is. Until you have
181/// one, describe a container-run agent as **contained**, not *isolated*.
182///
183/// The defaults are the ones that cannot break a working agent. Resource
184/// *limits* are not among them: there is no memory ceiling that is right for
185/// every agent, and one guessed here would show up as an agent that dies under
186/// load for no visible reason. They are opt-in, per deployment
187/// ([`with_memory`](Self::with_memory), [`with_cpus`](Self::with_cpus)).
188#[derive(Debug, Clone)]
189pub struct ContainerHardening {
190 /// Drop every Linux capability. An agent binds a port above 1024 and talks
191 /// HTTP; it needs none of them.
192 pub drop_capabilities: bool,
193 /// Forbid gaining privileges through setuid binaries (`no-new-privileges`).
194 pub no_new_privileges: bool,
195 /// Mount the root filesystem read-only **where the agent's config allows
196 /// it** — see [`needs_writable_rootfs`]. A `/tmp` tmpfs is mounted alongside
197 /// so an agent still has scratch space.
198 pub read_only_rootfs: bool,
199 /// Cap on processes in the container.
200 pub pids_limit: Option<u32>,
201 /// Memory ceiling, in the engine's own notation (`"512m"`, `"2g"`).
202 /// Passed through verbatim — the engine is the authority on the format.
203 pub memory: Option<String>,
204 /// CPU allowance, in the engine's own notation (`"0.5"`, `"2"`).
205 pub cpus: Option<String>,
206}
207
208impl Default for ContainerHardening {
209 fn default() -> Self {
210 Self {
211 drop_capabilities: true,
212 no_new_privileges: true,
213 read_only_rootfs: true,
214 pids_limit: Some(DEFAULT_PIDS_LIMIT),
215 memory: None,
216 cpus: None,
217 }
218 }
219}
220
221impl ContainerHardening {
222 /// Every restriction off — containers as they were before hardening.
223 ///
224 /// The escape hatch for an agent that genuinely needs what the defaults
225 /// remove (a handler writing outside `/tmp`, an image with a setuid helper).
226 /// Prefer narrowing one field of [`Default`] to reaching for this.
227 pub fn none() -> Self {
228 Self {
229 drop_capabilities: false,
230 no_new_privileges: false,
231 read_only_rootfs: false,
232 pids_limit: None,
233 memory: None,
234 cpus: None,
235 }
236 }
237
238 /// Set the memory ceiling (engine notation, e.g. `"512m"`).
239 pub fn with_memory(mut self, memory: impl Into<String>) -> Self {
240 self.memory = Some(memory.into());
241 self
242 }
243
244 /// Set the CPU allowance (engine notation, e.g. `"1.5"`).
245 pub fn with_cpus(mut self, cpus: impl Into<String>) -> Self {
246 self.cpus = Some(cpus.into());
247 self
248 }
249
250 /// The `create` flags this policy contributes, given whether *this* agent
251 /// needs to write outside `/tmp`.
252 ///
253 /// Kept separate from the rest of the argv so the policy can be read, and
254 /// tested, on its own.
255 fn args(&self, writable_rootfs_needed: bool) -> Vec<String> {
256 let mut args = Vec::new();
257 if self.drop_capabilities {
258 args.push("--cap-drop=ALL".to_string());
259 }
260 if self.no_new_privileges {
261 args.push("--security-opt=no-new-privileges".to_string());
262 }
263 if self.read_only_rootfs && !writable_rootfs_needed {
264 args.push("--read-only".to_string());
265 args.push("--tmpfs".to_string());
266 args.push(CONTAINER_TMPFS.to_string());
267 }
268 if let Some(pids) = self.pids_limit {
269 args.push("--pids-limit".to_string());
270 args.push(pids.to_string());
271 }
272 if let Some(memory) = &self.memory {
273 args.push("--memory".to_string());
274 args.push(memory.clone());
275 }
276 if let Some(cpus) = &self.cpus {
277 args.push("--cpus".to_string());
278 args.push(cpus.clone());
279 }
280 args
281 }
282}
283
284/// Whether an agent's config requires a writable root filesystem.
285///
286/// Derived from the config rather than left to the operator, because getting it
287/// wrong in either direction is bad in a way that is hard to diagnose: a
288/// read-only `sqlx` agent crash-loops on a disk error, and a writable in-memory
289/// one gives up the protection for nothing.
290///
291/// The rule is deliberately conservative — only [`StorageConfig::InMemory`] is
292/// known to write nothing. A `sqlx` agent might be pointed at Postgres over the
293/// network and never touch the disk, but that would mean deciding from a URL
294/// string, and being wrong there means an agent that will not start.
295fn needs_writable_rootfs(config: &AgentConfig) -> bool {
296 !matches!(config.server.storage, StorageConfig::InMemory)
297}
298
299/// Which image runs an agent, and therefore how it is booted.
300///
301/// The two differ in one thing only: whether this adapter appends a command.
302/// The base image is *this* project's binary, so the argv that starts it is
303/// known here; someone else's image is not, so its own `ENTRYPOINT`/`CMD` has to
304/// decide, and everything it needs arrives as the mount and the environment.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306enum AgentImage<'a> {
307 /// The platform's own image, started with `a2a run --config <mount>`.
308 Base(&'a str),
309 /// The agent's own image (`[runtime] image`), started as it defines itself.
310 Own(&'a str),
311}
312
313impl<'a> AgentImage<'a> {
314 /// Pick the image for one agent: its own where it named one, else the
315 /// runtime's base.
316 fn resolve(spec_image: Option<&'a str>, base: &'a str) -> Self {
317 match spec_image {
318 Some(image) => AgentImage::Own(image),
319 None => AgentImage::Base(base),
320 }
321 }
322
323 /// The image reference, for the `create` argv.
324 fn reference(&self) -> &'a str {
325 match self {
326 AgentImage::Base(image) | AgentImage::Own(image) => image,
327 }
328 }
329
330 /// The command appended after the image — empty for an agent's own image.
331 fn command(&self) -> Vec<String> {
332 match self {
333 AgentImage::Base(_) => vec![
334 "run".to_string(),
335 "--config".to_string(),
336 CONTAINER_CONFIG_PATH.to_string(),
337 ],
338 AgentImage::Own(_) => Vec::new(),
339 }
340 }
341}
342
343/// Everything [`create_args`] needs to build one agent's `create` argv.
344struct CreateParams<'a> {
345 image: AgentImage<'a>,
346 id: &'a AgentId,
347 port: u16,
348 config_path: &'a Path,
349 /// Names only — see [`create_args`].
350 env_refs: &'a [String],
351 hardening: &'a ContainerHardening,
352 /// Whether this agent's storage rules out a read-only root filesystem.
353 writable_rootfs_needed: bool,
354}
355
356/// Build the `docker create` argv that runs an agent: publish its port, apply
357/// the hardening policy, inject the config as a read-only mount, bind all
358/// interfaces, and pass through the env vars the config references.
359///
360/// That set is the contract every agent image gets, whoever built it: the config
361/// at [`CONTAINER_CONFIG_PATH`] and named by [`CONFIG_PATH_ENV`], `HOST`, the
362/// published port, and the allowed variables. Only the base image also gets a
363/// command ([`AgentImage::command`]).
364///
365/// `env_refs` are passed as value-less `-e VAR` flags: the engine CLI reads
366/// each value from its own environment (this process's), so secret values never
367/// appear in the argv, and a variable unset here is simply not set in the
368/// container (letting an in-config `${VAR:-default}` apply).
369fn create_args(params: CreateParams<'_>) -> Vec<String> {
370 let CreateParams {
371 image,
372 id,
373 port,
374 config_path,
375 env_refs,
376 hardening,
377 writable_rootfs_needed,
378 } = params;
379
380 let mut args = vec![
381 "create".to_string(),
382 "--name".to_string(),
383 container_name(id),
384 "-p".to_string(),
385 format!("{port}:{port}"),
386 "-e".to_string(),
387 "HOST=0.0.0.0".to_string(),
388 "-e".to_string(),
389 format!("{CONFIG_PATH_ENV}={CONTAINER_CONFIG_PATH}"),
390 ];
391 for var in env_refs {
392 args.push("-e".to_string());
393 args.push(var.clone());
394 }
395 args.extend(hardening.args(writable_rootfs_needed));
396 args.extend([
397 "-v".to_string(),
398 format!("{}:{CONTAINER_CONFIG_PATH}:ro", config_path.display()),
399 // These two labels are how restart-recovery finds the fleet again
400 // (`recover` reads them straight back). Dropping either silently costs
401 // every agent on the next bounce.
402 "--label".to_string(),
403 format!("{LABEL_AGENT}={id}"),
404 "--label".to_string(),
405 format!("{LABEL_PORT}={port}"),
406 image.reference().to_string(),
407 ]);
408 args.extend(image.command());
409 args
410}
411
412/// A `--format` template printing one label's value (the Go-template braces are
413/// doubled because this is a `format!` string).
414fn label_template(label: &str) -> String {
415 format!("{{{{.Label \"{label}\"}}}}")
416}
417
418/// Read back what [`create_args`] stamped: one `<id>\t<port>` line per
419/// container.
420///
421/// Lines missing either label (a container labelled by an older version, or by
422/// something else entirely) are skipped with a warning rather than failing the
423/// whole recovery — one unrecognizable container must not cost the operator the
424/// rest of the fleet.
425fn parse_labelled_containers(output: &str) -> Vec<(AgentId, u16)> {
426 output
427 .lines()
428 .map(str::trim)
429 .filter(|line| !line.is_empty())
430 .filter_map(|line| match line.split_once('\t') {
431 Some((id, port)) if !id.is_empty() => match port.trim().parse::<u16>() {
432 Ok(port) => Some((AgentId::from(id.trim()), port)),
433 Err(_) => {
434 warn!("skipping container '{id}': unreadable {LABEL_PORT} label '{port}'");
435 None
436 }
437 },
438 _ => {
439 warn!("skipping container: unexpected `ps` line '{line}'");
440 None
441 }
442 })
443 .collect()
444}
445
446/// Build the `docker logs` argv for an agent's container.
447///
448/// `--timestamps` because the reason anyone reads these is to line an agent's
449/// output up against something else that happened.
450fn logs_args(id: &AgentId, tail: Option<usize>) -> Vec<String> {
451 let mut args = vec!["logs".to_string(), "--timestamps".to_string()];
452 if let Some(n) = tail {
453 args.push("--tail".to_string());
454 args.push(n.to_string());
455 }
456 args.push(container_name(id));
457 args
458}
459
460/// Run the engine with `args`, returning **both** output streams concatenated.
461///
462/// Distinct from [`run_engine`] because `docker logs` replays the container's
463/// stdout on stdout and its stderr on stderr — and an agent's `tracing` output
464/// goes to stderr, so reading stdout alone would show an operator an empty log
465/// for an agent that is printing the very error they are looking for.
466///
467/// The two streams are captured separately by the OS pipe, so interleaving is
468/// lost; stdout is emitted first. Ordering *within* each stream is preserved,
469/// which is what the timestamps make usable.
470async fn run_engine_combined(engine: &str, args: &[String]) -> Result<String, RuntimeError> {
471 let output = Command::new(engine)
472 .args(args)
473 .output()
474 .await
475 .map_err(|e| RuntimeError::Backend(format!("could not run `{engine}`: {e}")))?;
476 if !output.status.success() {
477 let verb = args.first().map(String::as_str).unwrap_or("");
478 let stderr = String::from_utf8_lossy(&output.stderr);
479 return Err(RuntimeError::Backend(format!(
480 "`{engine} {verb}` failed: {}",
481 stderr.trim()
482 )));
483 }
484 let mut combined = String::from_utf8_lossy(&output.stdout).into_owned();
485 combined.push_str(&String::from_utf8_lossy(&output.stderr));
486 Ok(combined)
487}
488
489/// Run the engine with `args`, returning trimmed stdout. A spawn failure or a
490/// non-zero exit (with stderr) becomes [`RuntimeError::Backend`].
491async fn run_engine(engine: &str, args: &[String]) -> Result<String, RuntimeError> {
492 let output = Command::new(engine)
493 .args(args)
494 .output()
495 .await
496 .map_err(|e| RuntimeError::Backend(format!("could not run `{engine}`: {e}")))?;
497 if !output.status.success() {
498 let verb = args.first().map(String::as_str).unwrap_or("");
499 let stderr = String::from_utf8_lossy(&output.stderr);
500 return Err(RuntimeError::Backend(format!(
501 "`{engine} {verb}` failed: {}",
502 stderr.trim()
503 )));
504 }
505 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
506}
507
508#[async_trait]
509impl AgentRuntime for ContainerRuntime {
510 async fn provision(&self, spec: AgentSpec) -> Result<AgentId, RuntimeError> {
511 let id = spec.id.clone();
512 let content = tokio::fs::read_to_string(&spec.config_path)
513 .await
514 .map_err(|e| RuntimeError::Config(e.to_string()))?;
515 // Vet the *raw* config against the allowlist before parsing: parsing
516 // expands `${VAR}` against this process's env and reports set vs. unset
517 // differently, so a rejected config must never reach it — otherwise it
518 // could probe which secrets the control plane holds. The vars that
519 // survive are what gets passed through into the container (where the
520 // in-container `a2a run` re-expands them), keeping secrets out of the
521 // bind-mounted TOML.
522 let env_refs = self.allowed_env.check(&content)?;
523 // The published port is the agent's configured HTTP port. Parsing here
524 // also expands `${VAR}` refs against *this* process's env, so a missing
525 // secret fails provisioning instead of crash-looping the container.
526 let config =
527 AgentConfig::from_toml(&content).map_err(|e| RuntimeError::Config(e.to_string()))?;
528 let port = config.server.http_port;
529
530 // Clear a stale container of the same name so re-provision is idempotent.
531 let _ = run_engine(
532 &self.engine,
533 &["rm".to_string(), "-f".to_string(), container_name(&id)],
534 )
535 .await;
536
537 run_engine(
538 &self.engine,
539 &create_args(CreateParams {
540 image: AgentImage::resolve(spec.image.as_deref(), &self.base_image),
541 id: &id,
542 port,
543 config_path: &spec.config_path,
544 env_refs: &env_refs,
545 hardening: &self.hardening,
546 writable_rootfs_needed: needs_writable_rootfs(&config),
547 }),
548 )
549 .await?;
550 self.agents.lock().await.insert(id.clone(), port);
551 Ok(id)
552 }
553
554 /// Rebuild the id → port map from the engine, which kept running while this
555 /// process was not.
556 ///
557 /// `ps -a` on purpose: a stopped-but-existing container is still an agent
558 /// this runtime manages, and adopting it is what makes `stop`/`undeploy`
559 /// work on it after a restart instead of returning `NotFound`.
560 async fn recover(&self) -> Result<Recovered<AgentId>, RuntimeError> {
561 let output = run_engine(
562 &self.engine,
563 &[
564 "ps".to_string(),
565 "-a".to_string(),
566 "--filter".to_string(),
567 format!("label={LABEL_AGENT}"),
568 "--format".to_string(),
569 format!(
570 "{}\t{}",
571 label_template(LABEL_AGENT),
572 label_template(LABEL_PORT)
573 ),
574 ],
575 )
576 .await?;
577
578 let found = parse_labelled_containers(&output);
579 let mut agents = self.agents.lock().await;
580 let mut adopted = Vec::with_capacity(found.len());
581 for (id, port) in found {
582 agents.insert(id.clone(), port);
583 adopted.push(id);
584 }
585 // Deterministic for reporting; the engine's order is its own business.
586 adopted.sort_by(|a, b| a.as_str().cmp(b.as_str()));
587 Ok(Recovered::Adopted(adopted))
588 }
589
590 async fn start(&self, id: &AgentId) -> Result<(), RuntimeError> {
591 let name = {
592 let guard = self.agents.lock().await;
593 if !guard.contains_key(id) {
594 return Err(RuntimeError::NotFound(id.clone()));
595 }
596 container_name(id)
597 };
598 if self.inspect_status(&name).await.as_deref() == Some("running") {
599 return Err(RuntimeError::AlreadyRunning(id.clone()));
600 }
601 run_engine(&self.engine, &["start".to_string(), name]).await?;
602 Ok(())
603 }
604
605 async fn stop(&self, id: &AgentId) -> Result<(), RuntimeError> {
606 let name = {
607 let guard = self.agents.lock().await;
608 if !guard.contains_key(id) {
609 return Err(RuntimeError::NotFound(id.clone()));
610 }
611 container_name(id)
612 };
613 run_engine(&self.engine, &["stop".to_string(), name]).await?;
614 Ok(())
615 }
616
617 async fn health(&self, id: &AgentId) -> Result<RuntimeHealth, RuntimeError> {
618 let (name, port) = {
619 let guard = self.agents.lock().await;
620 let port = *guard
621 .get(id)
622 .ok_or_else(|| RuntimeError::NotFound(id.clone()))?;
623 (container_name(id), port)
624 };
625 match self.inspect_status(&name).await.as_deref() {
626 Some("created") => Ok(RuntimeHealth::Provisioned),
627 Some("running") => {
628 match a2a_rs::fetch_agent_card(&format!("http://127.0.0.1:{port}")).await {
629 Ok(_) => Ok(RuntimeHealth::Healthy),
630 Err(_) => Ok(RuntimeHealth::Unhealthy),
631 }
632 }
633 // exited / dead / paused / removed-out-of-band
634 _ => Ok(RuntimeHealth::Stopped),
635 }
636 }
637
638 async fn list(&self) -> Result<Vec<RuntimeStatus>, RuntimeError> {
639 let ids: Vec<(AgentId, u16)> = {
640 let guard = self.agents.lock().await;
641 guard.iter().map(|(id, port)| (id.clone(), *port)).collect()
642 };
643 let mut statuses = Vec::with_capacity(ids.len());
644 for (id, port) in ids {
645 let health = self.health(&id).await?;
646 statuses.push(RuntimeStatus {
647 id,
648 health,
649 endpoint: format!("http://127.0.0.1:{port}"),
650 });
651 }
652 Ok(statuses)
653 }
654
655 /// Replay the container's output via the engine, which retains it for the
656 /// container's whole life — including after it exits, which is exactly when
657 /// the log matters most.
658 async fn logs(&self, id: &AgentId, tail: Option<usize>) -> Result<Vec<String>, RuntimeError> {
659 if !self.agents.lock().await.contains_key(id) {
660 return Err(RuntimeError::NotFound(id.clone()));
661 }
662 let output = run_engine_combined(&self.engine, &logs_args(id, tail)).await?;
663 // The engine already applied `--tail`; trimming again is only to drop a
664 // trailing blank line.
665 Ok(tail_lines(&output, None))
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 #[test]
674 fn container_name_is_prefixed_slug() {
675 assert_eq!(
676 container_name(&AgentId::from_name("Weather Agent")),
677 "a2a-agent-weather-agent"
678 );
679 }
680
681 /// Build an argv the way `provision` would, so the tests below differ from
682 /// production only in the values they vary.
683 fn args_for(
684 id: &AgentId,
685 port: u16,
686 env_refs: &[String],
687 hardening: &ContainerHardening,
688 writable_rootfs_needed: bool,
689 ) -> Vec<String> {
690 image_args_for(
691 AgentImage::Base("a2a-agents:latest"),
692 id,
693 port,
694 env_refs,
695 hardening,
696 writable_rootfs_needed,
697 )
698 }
699
700 /// As [`args_for`], for the tests that vary which image runs the agent.
701 fn image_args_for(
702 image: AgentImage<'_>,
703 id: &AgentId,
704 port: u16,
705 env_refs: &[String],
706 hardening: &ContainerHardening,
707 writable_rootfs_needed: bool,
708 ) -> Vec<String> {
709 create_args(CreateParams {
710 image,
711 id,
712 port,
713 config_path: Path::new("/cfg/echo.toml"),
714 env_refs,
715 hardening,
716 writable_rootfs_needed,
717 })
718 }
719
720 #[test]
721 fn create_args_build_expected_argv() {
722 let id = AgentId::from_name("Echo Agent");
723 // No hardening, so this pins the shape of everything else.
724 let args = args_for(&id, 8080, &[], &ContainerHardening::none(), false);
725 assert_eq!(
726 args,
727 vec![
728 "create",
729 "--name",
730 "a2a-agent-echo-agent",
731 "-p",
732 "8080:8080",
733 "-e",
734 "HOST=0.0.0.0",
735 "-e",
736 "A2A_CONFIG=/etc/agent.toml",
737 "-v",
738 "/cfg/echo.toml:/etc/agent.toml:ro",
739 "--label",
740 "a2a-agent=echo-agent",
741 "--label",
742 "a2a-port=8080",
743 "a2a-agents:latest",
744 "run",
745 "--config",
746 "/etc/agent.toml",
747 ]
748 );
749 }
750
751 /// An agent's own image is run as it defines itself. Appending the base
752 /// image's `run --config …` would override its `CMD` with an argv from a
753 /// binary it has no reason to contain.
754 #[test]
755 fn an_agents_own_image_is_started_without_a_command() {
756 let id = AgentId::from_name("Billing Agent");
757 let args = image_args_for(
758 AgentImage::Own("ghcr.io/acme/billing:1.4"),
759 &id,
760 8080,
761 &[],
762 &ContainerHardening::none(),
763 false,
764 );
765
766 assert_eq!(
767 args.last().map(String::as_str),
768 Some("ghcr.io/acme/billing:1.4")
769 );
770 assert!(
771 !args.iter().any(|a| a == "run" || a == "--config"),
772 "someone else's image must boot by its own ENTRYPOINT: {args:?}"
773 );
774 }
775
776 /// Everything an image needs to find its config arrives the same way for
777 /// both — the mount plus the variable naming it — since an image that is not
778 /// this project's binary cannot be assumed to know the path.
779 #[test]
780 fn every_image_gets_the_same_config_contract() {
781 let id = AgentId::from_name("Billing Agent");
782 let hardening = ContainerHardening::none();
783 for image in [
784 AgentImage::Base("a2a-agents:latest"),
785 AgentImage::Own("ghcr.io/acme/billing:1.4"),
786 ] {
787 let args = image_args_for(image, &id, 8080, &[], &hardening, false);
788 assert!(
789 args.iter().any(|a| a == "A2A_CONFIG=/etc/agent.toml"),
790 "{image:?} must be told where its config is: {args:?}"
791 );
792 assert!(
793 args.iter()
794 .any(|a| a == "/cfg/echo.toml:/etc/agent.toml:ro"),
795 "{image:?} must have its config mounted: {args:?}"
796 );
797 assert!(
798 args.iter().any(|a| a == "HOST=0.0.0.0"),
799 "{image:?} must be told to bind all interfaces: {args:?}"
800 );
801 }
802 }
803
804 /// The agent's own image wins over whatever base the runtime was built with;
805 /// an agent that named none gets that base.
806 #[test]
807 fn a_spec_image_overrides_the_runtimes_base() {
808 assert_eq!(
809 AgentImage::resolve(Some("ghcr.io/acme/billing:1.4"), "a2a-agents:latest"),
810 AgentImage::Own("ghcr.io/acme/billing:1.4")
811 );
812 assert_eq!(
813 AgentImage::resolve(None, "a2a-agents:latest"),
814 AgentImage::Base("a2a-agents:latest")
815 );
816 }
817
818 /// Recovery reads back exactly what provisioning wrote, so the two must be
819 /// checked against each other — a label renamed on one side only would lose
820 /// the whole fleet on the next restart. Run against the **default**
821 /// hardening, since that is what production stamps.
822 #[test]
823 fn recovery_reads_back_what_create_stamped() {
824 let id = AgentId::from_name("Weather Agent");
825 let args = args_for(&id, 9100, &[], &ContainerHardening::default(), false);
826
827 // Pull the labels out of the argv the way the engine would store them.
828 let labels: Vec<&String> = args
829 .iter()
830 .enumerate()
831 .filter(|(i, _)| i > &0 && args[i - 1] == "--label")
832 .map(|(_, value)| value)
833 .collect();
834 let agent_label = labels
835 .iter()
836 .find_map(|l| l.strip_prefix(&format!("{LABEL_AGENT}=")))
837 .expect("agent label");
838 let port_label = labels
839 .iter()
840 .find_map(|l| l.strip_prefix(&format!("{LABEL_PORT}=")))
841 .expect("port label");
842
843 assert_eq!(
844 parse_labelled_containers(&format!("{agent_label}\t{port_label}")),
845 [(id, 9100)]
846 );
847 }
848
849 #[test]
850 fn parsing_skips_unusable_lines_but_keeps_the_rest() {
851 let output = "\
852weather-agent\t8080
853billing-agent\tnot-a-port
854\t9000
855orphan-container
856billing-agent\t8081
857";
858 // The two well-formed lines survive; a bad port, a missing id, and a
859 // container labelled by something else are dropped rather than aborting.
860 assert_eq!(
861 parse_labelled_containers(output),
862 [
863 (AgentId::from("weather-agent"), 8080),
864 (AgentId::from("billing-agent"), 8081),
865 ]
866 );
867 }
868
869 #[test]
870 fn label_template_is_a_go_template_for_one_label() {
871 assert_eq!(label_template("a2a-agent"), r#"{{.Label "a2a-agent"}}"#);
872 }
873
874 #[test]
875 fn logs_args_target_the_agents_container() {
876 let id = AgentId::from_name("Weather Agent");
877 assert_eq!(
878 logs_args(&id, None),
879 ["logs", "--timestamps", "a2a-agent-weather-agent"]
880 );
881 assert_eq!(
882 logs_args(&id, Some(50)),
883 [
884 "logs",
885 "--timestamps",
886 "--tail",
887 "50",
888 "a2a-agent-weather-agent"
889 ]
890 );
891 }
892
893 #[test]
894 fn create_args_pass_env_refs_through_by_name_only() {
895 let id = AgentId::from_name("LLM Agent");
896 let args = args_for(
897 &id,
898 8080,
899 &["API_TOKEN".to_string(), "OPENROUTER_API_KEY".to_string()],
900 &ContainerHardening::default(),
901 false,
902 );
903 // Value-less `-e VAR` flags, right after the adapter-owned variables, so
904 // secret values never appear in the argv.
905 let owned = args
906 .iter()
907 .position(|a| a == "A2A_CONFIG=/etc/agent.toml")
908 .unwrap();
909 assert_eq!(
910 &args[owned + 1..owned + 5],
911 ["-e", "API_TOKEN", "-e", "OPENROUTER_API_KEY"]
912 );
913 assert!(!args.iter().any(|a| a.contains('=') && a.contains("TOKEN")));
914 }
915
916 /// What an operator gets without asking. Asserted by name rather than by
917 /// position: the value is that these flags are *present*, and pinning their
918 /// order would make any future addition a test edit for no reason.
919 #[test]
920 fn default_hardening_drops_privileges_and_caps_processes() {
921 let args = args_for(
922 &AgentId::from_name("Echo Agent"),
923 8080,
924 &[],
925 &ContainerHardening::default(),
926 false,
927 );
928 for expected in ["--cap-drop=ALL", "--security-opt=no-new-privileges"] {
929 assert!(args.iter().any(|a| a == expected), "missing {expected}");
930 }
931 let pids = args.iter().position(|a| a == "--pids-limit").expect("pids");
932 assert_eq!(args[pids + 1], DEFAULT_PIDS_LIMIT.to_string());
933
934 // No memory or CPU ceiling is guessed: one that is wrong shows up as an
935 // agent dying under load for no visible reason.
936 assert!(!args.iter().any(|a| a == "--memory" || a == "--cpus"));
937 }
938
939 /// The flags that would break a working agent are the ones that have to be
940 /// asked for.
941 #[test]
942 fn resource_ceilings_are_opt_in() {
943 let hardening = ContainerHardening::default()
944 .with_memory("512m")
945 .with_cpus("1.5");
946 let args = args_for(&AgentId::from("a"), 8080, &[], &hardening, false);
947
948 let memory = args.iter().position(|a| a == "--memory").expect("memory");
949 assert_eq!(args[memory + 1], "512m");
950 let cpus = args.iter().position(|a| a == "--cpus").expect("cpus");
951 assert_eq!(args[cpus + 1], "1.5");
952 }
953
954 /// A read-only root is applied only where the agent's storage allows it —
955 /// and never without somewhere to write, or an agent that only needed
956 /// scratch space would fail for the wrong reason.
957 #[test]
958 fn read_only_rootfs_follows_what_the_agent_stores() {
959 let hardening = ContainerHardening::default();
960
961 let in_memory = args_for(&AgentId::from("a"), 8080, &[], &hardening, false);
962 assert!(in_memory.iter().any(|a| a == "--read-only"));
963 let tmpfs = in_memory
964 .iter()
965 .position(|a| a == "--tmpfs")
966 .expect("tmpfs");
967 assert_eq!(in_memory[tmpfs + 1], CONTAINER_TMPFS);
968
969 // A `sqlx` agent writes its database; a read-only root would crash-loop
970 // it on a disk error that names nothing useful.
971 let writes = args_for(&AgentId::from("a"), 8080, &[], &hardening, true);
972 assert!(!writes.iter().any(|a| a == "--read-only" || a == "--tmpfs"));
973 // The rest of the policy still applies — one agent needing a writable
974 // disk must not opt it out of capability dropping.
975 assert!(writes.iter().any(|a| a == "--cap-drop=ALL"));
976 }
977
978 #[test]
979 fn hardening_can_be_turned_off_entirely() {
980 let args = args_for(
981 &AgentId::from("a"),
982 8080,
983 &[],
984 &ContainerHardening::none(),
985 false,
986 );
987 for flag in [
988 "--cap-drop=ALL",
989 "--security-opt=no-new-privileges",
990 "--read-only",
991 "--pids-limit",
992 ] {
993 assert!(!args.iter().any(|a| a == flag), "{flag} should be absent");
994 }
995 }
996
997 /// The rule that decides `--read-only`, driven through the real parser so it
998 /// is checked against configs an operator could actually write — including
999 /// the one that omits `[server.storage]` entirely and gets the in-memory
1000 /// default.
1001 #[test]
1002 fn only_in_memory_storage_is_known_not_to_write() {
1003 let config = |storage: &str| {
1004 AgentConfig::from_toml(&format!(
1005 r#"
1006[agent]
1007name = "Store"
1008
1009[handler]
1010type = "echo"
1011
1012[server]
1013http_port = 8080
1014{storage}
1015"#
1016 ))
1017 .expect("parse")
1018 };
1019
1020 assert!(!needs_writable_rootfs(&config("")));
1021 assert!(!needs_writable_rootfs(&config(
1022 "\n[server.storage]\ntype = \"inmemory\""
1023 )));
1024 assert!(needs_writable_rootfs(&config(
1025 "\n[server.storage]\ntype = \"sqlx\"\nurl = \"sqlite:agent.db\""
1026 )));
1027 }
1028}