Skip to main content

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}