Skip to main content

a2a_agents/control_plane/
mod.rs

1//! Control plane — compose the runtime, registry, and card-source ports into a
2//! platform.
3//!
4//! [`ControlPlane`] is the service (hex rule 9a) that owns an
5//! [`AgentRuntime`](crate::runtime::AgentRuntime), an
6//! [`AgentRegistry`](crate::registry::AgentRegistry), and a
7//! [`CardSource`](crate::registry::CardSource), and orchestrates the use-cases
8//! that span them: deploying an agent both *runs* it (via the runtime) and
9//! *publishes its card* (via the registry) so peers discover it, and
10//! [`recover`](ControlPlane::recover) rebuilds both halves after a restart. It
11//! is assembled at the edge with concrete adapters injected — today a
12//! [`LocalProcessRuntime`](crate::runtime::LocalProcessRuntime) +
13//! [`InMemoryAgentRegistry`](crate::registry::InMemoryAgentRegistry), a container
14//! runtime / persistent registry later, with no change here.
15//!
16//! **Startup order matters:** call [`recover`](ControlPlane::recover) before
17//! serving. Everything the control plane knows is process state, so a restart
18//! that skips it answers `GET /agents` with `[]` while the agents are still up.
19//!
20//! [`control_plane_router`] exposes the service over HTTP, and
21//! [`ControlPlaneClient`] is the other side of that contract — what `a2a
22//! deploy/ps/logs/stop` drive, and what the Terraform provider will target. The
23//! bodies both adapters agree on live in [`wire`].
24
25mod auth;
26mod client;
27mod http;
28mod wire;
29
30pub use auth::ControlPlaneAuth;
31pub use client::{ControlPlaneClient, ControlPlaneClientError};
32pub use http::control_plane_router;
33pub use wire::{AgentLogs, AgentStatus, ApiErrorBody, DeployRequest, ListQuery, LogsQuery};
34
35use std::path::PathBuf;
36use std::sync::Arc;
37
38use serde::{Deserialize, Serialize};
39use thiserror::Error;
40use tracing::warn;
41
42use crate::core::AgentBuilder;
43use crate::core::config::ConfigError;
44use crate::registry::{AgentId, AgentRegistry, CardSource, RegistryError};
45use crate::runtime::{
46    AgentRuntime, AgentSpec, EnvAllowlist, Recovered, RuntimeError, RuntimeHealth,
47};
48
49/// A deployed agent's id, endpoint, and current health — the control-plane DTO.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct DeployedAgent {
52    /// The agent's id (slug of its name), shared by runtime and registry.
53    pub id: String,
54    /// The endpoint the agent serves on.
55    pub endpoint: String,
56    /// Its current runtime health.
57    pub health: RuntimeHealth,
58}
59
60/// Errors a control-plane operation can return.
61#[derive(Debug, Error)]
62pub enum ControlPlaneError {
63    /// A runtime operation failed (spawn, not-found, already-running, …).
64    #[error(transparent)]
65    Runtime(#[from] RuntimeError),
66
67    /// A registry operation failed.
68    #[error(transparent)]
69    Registry(#[from] RegistryError),
70
71    /// The agent's config could not be loaded or parsed.
72    #[error("invalid agent config: {0}")]
73    Config(#[from] ConfigError),
74
75    /// The agent card could not be built from the config.
76    #[error("could not build agent card: {0}")]
77    Card(String),
78
79    /// The port this agent would bind is already claimed by a different agent in
80    /// the live fleet. Same rule as
81    /// [`FleetConflict::Port`](crate::core::FleetConflict::Port), checked against
82    /// what is deployed rather than within one submission.
83    #[error(
84        "port {port} is already claimed by agent '{holder}' — change this agent's \
85         `[server] http_port`, or stop '{holder}' first"
86    )]
87    PortInUse {
88        /// The contested port.
89        port: u16,
90        /// The id of the agent already holding it.
91        holder: String,
92    },
93}
94
95/// A submitted config that has passed policy checks **and** parsed cleanly.
96///
97/// Only [`ControlPlane::prepare`] can construct one, and [`ControlPlane::deploy`]
98/// accepts nothing else — so an unvetted config cannot reach the runtime by
99/// forgetting a call. The caller gets the [`AgentId`] out in order to name the
100/// file it materializes, which is why preparing and deploying are two steps.
101pub struct PreparedDeploy {
102    builder: AgentBuilder,
103    id: AgentId,
104}
105
106impl PreparedDeploy {
107    /// The agent's id, derived from its name. Stable across prepare → deploy.
108    pub fn id(&self) -> &AgentId {
109        &self.id
110    }
111}
112
113/// Deliberately shows only the id. The inner config has had its `${VAR}` refs
114/// **expanded**, so a derived `Debug` would print resolved secrets into any log
115/// line that formats a `PreparedDeploy`.
116impl std::fmt::Debug for PreparedDeploy {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.debug_struct("PreparedDeploy")
119            .field("id", &self.id)
120            .finish_non_exhaustive()
121    }
122}
123
124/// Owns the runtime + registry + card-source ports and drives the deploy,
125/// undeploy, and recovery use-cases across them.
126#[derive(Clone)]
127pub struct ControlPlane {
128    runtime: Arc<dyn AgentRuntime>,
129    registry: Arc<dyn AgentRegistry>,
130    cards: Arc<dyn CardSource>,
131    allowed_env: EnvAllowlist,
132}
133
134impl ControlPlane {
135    /// Assemble a control plane over concrete adapters.
136    ///
137    /// `cards` reads a running agent's published card; it is required rather
138    /// than optional because [`recover`](Self::recover) needs it to put adopted
139    /// agents back into discovery, and a control plane that silently skipped
140    /// that step would come back from a restart supervising agents nobody can
141    /// find.
142    ///
143    /// The env allowlist is deny-all until set with
144    /// [`with_allowed_env`](Self::with_allowed_env).
145    pub fn new(
146        runtime: Arc<dyn AgentRuntime>,
147        registry: Arc<dyn AgentRegistry>,
148        cards: Arc<dyn CardSource>,
149    ) -> Self {
150        Self {
151            runtime,
152            registry,
153            cards,
154            allowed_env: EnvAllowlist::deny_all(),
155        }
156    }
157
158    /// Permit submitted configs to reference these host environment variables.
159    ///
160    /// Give the runtime adapter the *same* allowlist at the composition edge:
161    /// this one vets what the API accepts, the runtime's decides what actually
162    /// crosses into the agent. One policy value, two enforcement points — the
163    /// runtime stays safe even when something other than this service drives it.
164    pub fn with_allowed_env(mut self, allowed: EnvAllowlist) -> Self {
165        self.allowed_env = allowed;
166        self
167    }
168
169    /// Vet and parse a submitted config, yielding the token [`deploy`](Self::deploy)
170    /// requires.
171    ///
172    /// The allowlist is checked against the **raw** text first. Parsing expands
173    /// `${VAR}` against this process's environment and distinguishes a set from
174    /// an unset variable in its error, so a config naming a forbidden secret must
175    /// be rejected before it can be used to probe what secrets exist here.
176    pub fn prepare(&self, raw_toml: &str) -> Result<PreparedDeploy, ControlPlaneError> {
177        self.allowed_env.check(raw_toml)?;
178        let builder = AgentBuilder::from_toml(raw_toml)?;
179        let id = AgentId::from_name(&builder.config().agent.name);
180        Ok(PreparedDeploy { builder, id })
181    }
182
183    /// Deploy a [`prepare`](Self::prepare)d agent: provision + start it in the
184    /// runtime, then register its card so peers discover it. The runtime and
185    /// registry ids coincide (both the slug of the agent name).
186    ///
187    /// The caller materializes the config on disk — at the path
188    /// `prepared.id()` told it to use — and passes that `config_path` (the file
189    /// the runtime reads). So the service orchestrates ports + pure config
190    /// without itself touching the filesystem (hex rule 9a), and the TOML is
191    /// parsed exactly once, during `prepare`.
192    ///
193    /// Deploying onto a port another agent already holds is
194    /// [`PortInUse`](ControlPlaneError::PortInUse), not a started agent — see
195    /// [`check_port_free`](Self::check_port_free).
196    pub async fn deploy(
197        &self,
198        prepared: PreparedDeploy,
199        config_path: PathBuf,
200    ) -> Result<DeployedAgent, ControlPlaneError> {
201        let PreparedDeploy { builder, id } = prepared;
202        let config = builder.config();
203        let spec = AgentSpec {
204            id,
205            config_path,
206            endpoint: config.agent_url(),
207            image: config.image().map(str::to_string),
208        };
209        let endpoint = spec.endpoint.clone();
210
211        self.check_port_free(&spec).await?;
212
213        // Build the card before mutating any state, so a bad card fails the
214        // deploy without leaving a half-started agent behind.
215        let card = builder
216            .agent_card()
217            .await
218            .map_err(|e| ControlPlaneError::Card(e.to_string()))?;
219
220        let id = self.runtime.provision(spec).await?;
221        self.runtime.start(&id).await?;
222        self.registry.register(card, endpoint.clone()).await?;
223
224        let health = self.runtime.health(&id).await?;
225        Ok(DeployedAgent {
226            id: id.to_string(),
227            endpoint,
228            health,
229        })
230    }
231
232    /// Reject a spec whose port a *different* live agent already holds.
233    ///
234    /// Without it, the second deploy reports `ok … healthy` and is wrong twice
235    /// over: the agent's process loses the bind race and dies, while the card
236    /// probe — which only knows the endpoint — answers from the agent that won
237    /// it, so the deploy looks successful and `ps` admits the truth only later.
238    /// `a2a up` runs the same rule over a fleet file before anything binds; here
239    /// it runs against the live fleet, which is the only thing the deploying
240    /// client cannot see.
241    ///
242    /// A stopped agent holds nothing, so its (still-listed) entry never blocks a
243    /// deploy. Redeploying the *same* id is not a conflict — that falls through
244    /// to the runtime, which answers
245    /// [`AlreadyRunning`](RuntimeError::AlreadyRunning).
246    async fn check_port_free(&self, spec: &AgentSpec) -> Result<(), ControlPlaneError> {
247        // Port 0 means "no HTTP server" (MCP-only agents), so it never clashes —
248        // the same exemption `fleet_conflicts` makes.
249        let Some(port) = endpoint_port(&spec.endpoint).filter(|port| *port != 0) else {
250            return Ok(());
251        };
252        for status in self.runtime.list().await? {
253            if status.id != spec.id
254                && status.health != RuntimeHealth::Stopped
255                && endpoint_port(&status.endpoint) == Some(port)
256            {
257                return Err(ControlPlaneError::PortInUse {
258                    port,
259                    holder: status.id.to_string(),
260                });
261            }
262        }
263        Ok(())
264    }
265
266    /// Re-adopt the agents this platform was already running, and put the ones
267    /// that answer back into discovery.
268    ///
269    /// Call once at startup, before serving. Both halves of the control plane's
270    /// knowledge are process state — the runtime's instance table and the
271    /// registry's cards — so without this a restarted control plane reports an
272    /// empty fleet while the agents are still up: `GET /agents` says nothing is
273    /// deployed, `DELETE` says `NotFound`, peers stop resolving each other by
274    /// skill, and a Terraform `Read` concludes the agents were destroyed
275    /// out-of-band and recreates them on top of the running ones.
276    ///
277    /// Registration is attempted only for agents the runtime reports
278    /// [`Healthy`](RuntimeHealth::Healthy) — that state *means* the card probe
279    /// succeeded, so anything else would be a fetch known in advance to fail.
280    /// An agent that is adopted but not registered is still managed (it can be
281    /// stopped, and it will be registered by a later recovery once it answers);
282    /// it is simply not yet discoverable.
283    ///
284    /// Idempotent: adoption re-inserts by id and registration upserts by card
285    /// name, so calling this twice changes nothing.
286    pub async fn recover(&self) -> Result<Recovered<DeployedAgent>, ControlPlaneError> {
287        let adopted: std::collections::HashSet<AgentId> = match self.runtime.recover().await? {
288            Recovered::Ephemeral => return Ok(Recovered::Ephemeral),
289            Recovered::Adopted(ids) => ids.into_iter().collect(),
290        };
291
292        let mut recovered = Vec::with_capacity(adopted.len());
293        for status in self.runtime.list().await? {
294            if !adopted.contains(&status.id) {
295                continue;
296            }
297            if status.health == RuntimeHealth::Healthy {
298                match self.cards.fetch(&status.endpoint).await {
299                    Ok(card) => {
300                        self.registry
301                            .register(card, status.endpoint.clone())
302                            .await?;
303                    }
304                    // Reachable a moment ago, not now. Adopt it anyway: managing
305                    // it is what lets an operator stop it.
306                    Err(e) => warn!("recovered '{}' but could not register it: {e}", status.id),
307                }
308            } else {
309                warn!(
310                    "recovered '{}' as {:?}; not registered for discovery until it answers",
311                    status.id, status.health
312                );
313            }
314            recovered.push(DeployedAgent {
315                id: status.id.to_string(),
316                endpoint: status.endpoint,
317                health: status.health,
318            });
319        }
320        Ok(Recovered::Adopted(recovered))
321    }
322
323    /// Stop an agent in the runtime and deregister it. Idempotent on the
324    /// registry side (a missing entry is not an error).
325    pub async fn undeploy(&self, id: &AgentId) -> Result<(), ControlPlaneError> {
326        self.runtime.stop(id).await?;
327        match self.registry.deregister(id).await {
328            Ok(()) | Err(RegistryError::NotFound(_)) => Ok(()),
329        }
330    }
331
332    /// Report an agent's current runtime health.
333    pub async fn status(&self, id: &AgentId) -> Result<RuntimeHealth, ControlPlaneError> {
334        Ok(self.runtime.health(id).await?)
335    }
336
337    /// An agent's captured output, oldest line first, limited to the last `tail`
338    /// lines when given.
339    ///
340    /// Health says an agent is [`Unhealthy`](RuntimeHealth::Unhealthy); this is
341    /// where the reason is. Whether it can be answered at all depends on the
342    /// backend — see [`AgentRuntime::logs`].
343    pub async fn logs(
344        &self,
345        id: &AgentId,
346        tail: Option<usize>,
347    ) -> Result<Vec<String>, ControlPlaneError> {
348        Ok(self.runtime.logs(id, tail).await?)
349    }
350
351    /// List deployed agents with their endpoint and health, ordered by id.
352    ///
353    /// Sorted because a runtime's own order is its map's, i.e. arbitrary and
354    /// unstable between calls — which makes `a2a ps` shuffle between runs and
355    /// makes the API's response undiffable for no reason.
356    pub async fn list(&self, filter: ListFilter) -> Result<Vec<DeployedAgent>, ControlPlaneError> {
357        let mut agents: Vec<DeployedAgent> = self
358            .runtime
359            .list()
360            .await?
361            .into_iter()
362            .filter(|s| filter.includes(s.health))
363            .map(|s| DeployedAgent {
364                id: s.id.to_string(),
365                endpoint: s.endpoint,
366                health: s.health,
367            })
368            .collect();
369        agents.sort_by(|a, b| a.id.cmp(&b.id));
370        Ok(agents)
371    }
372}
373
374/// Which deployed agents a listing includes.
375///
376/// A runtime keeps an entry after the agent stops — deliberately, because that
377/// is when its log matters most and `logs` still answers for it. But an
378/// undeployed agent that keeps appearing in `a2a ps` reads as one that failed to
379/// go away, so a listing hides them unless asked. Same split as `docker ps` and
380/// `docker ps -a`, and for the same reason.
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
382pub enum ListFilter {
383    /// Only agents the runtime is still holding open: provisioned, healthy, or
384    /// unhealthy.
385    #[default]
386    Live,
387    /// Everything the runtime knows about, stopped agents included.
388    All,
389}
390
391impl ListFilter {
392    /// Whether an agent in this health belongs in the listing.
393    fn includes(self, health: RuntimeHealth) -> bool {
394        self == ListFilter::All || health != RuntimeHealth::Stopped
395    }
396}
397
398/// The TCP port an endpoint binds — `http://0.0.0.0:9090` → `9090`.
399///
400/// The port, not the whole endpoint, is what two agents contend for: they
401/// collide on one host whether they declared `0.0.0.0` or `127.0.0.1`. `None`
402/// for an endpoint carrying no port, which cannot collide with a known one.
403fn endpoint_port(endpoint: &str) -> Option<u16> {
404    endpoint
405        .rsplit(':')
406        .next()?
407        .trim_end_matches('/')
408        .parse()
409        .ok()
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::registry::{InMemoryAgentRegistry, InMemoryCardSource};
416    use crate::runtime::{InMemoryAgentRuntime, LocalProcessRuntime};
417    use a2a_rs::domain::AgentCard;
418
419    /// A temp echo-agent config the control plane can deploy. Removed on drop.
420    struct TempConfig {
421        path: std::path::PathBuf,
422    }
423
424    impl TempConfig {
425        fn echo(name: &str, port: u16) -> Self {
426            let path = std::env::temp_dir()
427                .join(format!("cp_test_{}_{port}.toml", AgentId::from_name(name)));
428            let toml = format!(
429                r#"
430[agent]
431name = "{name}"
432
433[handler]
434type = "echo"
435
436[server]
437host = "127.0.0.1"
438http_port = {port}
439
440[[skills]]
441id = "echo-skill"
442name = "Echo"
443"#
444            );
445            std::fs::write(&path, toml).unwrap();
446            Self { path }
447        }
448    }
449
450    impl Drop for TempConfig {
451        fn drop(&mut self) {
452            let _ = std::fs::remove_file(&self.path);
453        }
454    }
455
456    fn control_plane() -> (ControlPlane, Arc<dyn AgentRegistry>) {
457        let registry: Arc<dyn AgentRegistry> = Arc::new(InMemoryAgentRegistry::new());
458        let runtime: Arc<dyn AgentRuntime> = Arc::new(InMemoryAgentRuntime::new());
459        let cards = Arc::new(InMemoryCardSource::new());
460        (
461            ControlPlane::new(runtime, registry.clone(), cards),
462            registry,
463        )
464    }
465
466    /// A card an agent would serve at its endpoint.
467    fn card(name: &str) -> AgentCard {
468        AgentCard {
469            name: name.to_string(),
470            ..Default::default()
471        }
472    }
473
474    /// Read a temp config back and push it through the real prepare → deploy
475    /// pipeline, the way the HTTP adapter does.
476    fn prepared(cp: &ControlPlane, config: &TempConfig) -> PreparedDeploy {
477        let raw = std::fs::read_to_string(&config.path).unwrap();
478        cp.prepare(&raw).expect("prepare")
479    }
480
481    #[tokio::test]
482    async fn deploy_runs_and_registers_then_undeploy_tears_down() {
483        let (cp, registry) = control_plane();
484        let config = TempConfig::echo("Deploy Me", 8123);
485
486        let deployed = cp
487            .deploy(prepared(&cp, &config), config.path.clone())
488            .await
489            .expect("deploy");
490        assert_eq!(deployed.id, "deploy-me");
491        assert_eq!(deployed.endpoint, "http://127.0.0.1:8123");
492        assert_eq!(deployed.health, RuntimeHealth::Healthy);
493
494        // It is both running (status) and discoverable (registry).
495        let id = AgentId::from_name("Deploy Me");
496        assert_eq!(cp.status(&id).await.unwrap(), RuntimeHealth::Healthy);
497        assert!(registry.get(&id).await.unwrap().is_some());
498        assert_eq!(cp.list(ListFilter::All).await.unwrap().len(), 1);
499
500        // Undeploy stops it and removes it from discovery.
501        cp.undeploy(&id).await.expect("undeploy");
502        assert_eq!(cp.status(&id).await.unwrap(), RuntimeHealth::Stopped);
503        assert!(registry.get(&id).await.unwrap().is_none());
504    }
505
506    #[tokio::test]
507    async fn deploy_is_discoverable_by_skill() {
508        let (cp, registry) = control_plane();
509        let config = TempConfig::echo("Skilled Agent", 8124);
510
511        cp.deploy(prepared(&cp, &config), config.path.clone())
512            .await
513            .expect("deploy");
514
515        let matches = registry.find_by_skill("echo-skill").await.unwrap();
516        assert_eq!(matches.len(), 1);
517        assert_eq!(matches[0].card.name, "Skilled Agent");
518    }
519
520    /// `prepare` is the policy gate, and it must reject *before* parsing: a
521    /// config naming a secret this process holds must not be distinguishable
522    /// from one naming a secret it doesn't.
523    #[tokio::test]
524    async fn prepare_rejects_env_refs_outside_the_allowlist() {
525        let (cp, _registry) = control_plane();
526        let raw = r#"
527[agent]
528name = "Leaky"
529description = "${A2A_CP_TEST_SECRET}"
530
531[handler]
532type = "echo"
533
534[server]
535http_port = 8125
536"#;
537        let err = cp.prepare(raw).expect_err("deny-all must reject the ref");
538        assert!(
539            matches!(&err, ControlPlaneError::Runtime(RuntimeError::DisallowedEnv(v))
540                if v.contains("A2A_CP_TEST_SECRET")),
541            "got: {err}"
542        );
543
544        // Same config, same (unset) variable — permitted now, so it gets past the
545        // policy gate and fails on expansion instead. Different error ⇒ the gate
546        // is what rejected it above, not the missing value.
547        let permissive = ControlPlane::new(
548            Arc::new(InMemoryAgentRuntime::new()),
549            Arc::new(InMemoryAgentRegistry::new()),
550            Arc::new(InMemoryCardSource::new()),
551        )
552        .with_allowed_env(EnvAllowlist::new(["A2A_CP_TEST_SECRET"]));
553        assert!(
554            matches!(
555                permissive.prepare(raw),
556                Err(ControlPlaneError::Config(_)) | Ok(_)
557            ),
558            "an allow-listed var must clear the policy gate"
559        );
560    }
561
562    /// The failure this exists to prevent: the process restarts, the runtime
563    /// outlived it, but the registry did not — so a control plane that did not
564    /// recover would report an empty fleet while the agents are still serving.
565    #[tokio::test]
566    async fn recover_readopts_running_agents_and_rebuilds_discovery() {
567        let runtime: Arc<dyn AgentRuntime> = Arc::new(InMemoryAgentRuntime::new());
568        let cards = Arc::new(InMemoryCardSource::new());
569        let config = TempConfig::echo("Survivor", 8126);
570        let endpoint = "http://127.0.0.1:8126";
571        cards.insert(endpoint, card("Survivor")).await;
572
573        let before = ControlPlane::new(
574            runtime.clone(),
575            Arc::new(InMemoryAgentRegistry::new()),
576            cards.clone(),
577        );
578        before
579            .deploy(prepared(&before, &config), config.path.clone())
580            .await
581            .expect("deploy");
582
583        // The restart: same durable runtime, a brand-new (empty) registry.
584        let registry: Arc<dyn AgentRegistry> = Arc::new(InMemoryAgentRegistry::new());
585        let after = ControlPlane::new(runtime, registry.clone(), cards);
586        let id = AgentId::from_name("Survivor");
587        assert!(
588            after.list(ListFilter::All).await.unwrap().len() == 1,
589            "the runtime half survives on its own"
590        );
591        assert!(
592            registry.get(&id).await.unwrap().is_none(),
593            "…but discovery does not, which is what recovery has to fix"
594        );
595
596        let recovered = after.recover().await.expect("recover");
597        let Recovered::Adopted(agents) = recovered else {
598            panic!("a durable runtime must report Adopted, not Ephemeral");
599        };
600        assert_eq!(agents.len(), 1);
601        assert_eq!(agents[0].id, "survivor");
602        assert_eq!(agents[0].endpoint, endpoint);
603        assert_eq!(agents[0].health, RuntimeHealth::Healthy);
604
605        assert!(
606            registry.get(&id).await.unwrap().is_some(),
607            "a recovered agent must be discoverable again"
608        );
609
610        // And it is genuinely managed again, not just listed.
611        after.undeploy(&id).await.expect("undeploy after recovery");
612        assert_eq!(after.status(&id).await.unwrap(), RuntimeHealth::Stopped);
613    }
614
615    /// Recovery runs at every startup, including the first one.
616    #[tokio::test]
617    async fn recover_is_idempotent_and_fine_with_nothing_to_adopt() {
618        let (cp, registry) = control_plane();
619        assert_eq!(cp.recover().await.unwrap(), Recovered::Adopted(vec![]));
620
621        let config = TempConfig::echo("Twice", 8127);
622        cp.deploy(prepared(&cp, &config), config.path.clone())
623            .await
624            .expect("deploy");
625
626        // Two recoveries in a row leave exactly one of everything.
627        cp.recover().await.expect("first recover");
628        cp.recover().await.expect("second recover");
629        assert_eq!(cp.list(ListFilter::All).await.unwrap().len(), 1);
630        assert_eq!(registry.list().await.unwrap().len(), 1);
631    }
632
633    /// An adopted agent that will not hand over a card is still *managed* — the
634    /// operator can stop it — it is only missing from discovery.
635    #[tokio::test]
636    async fn an_unreachable_agent_is_adopted_but_not_registered() {
637        // Nothing inserted into the card source: every fetch fails.
638        let cards = Arc::new(InMemoryCardSource::new());
639        let registry: Arc<dyn AgentRegistry> = Arc::new(InMemoryAgentRegistry::new());
640        let cp = ControlPlane::new(
641            Arc::new(InMemoryAgentRuntime::new()),
642            registry.clone(),
643            cards,
644        );
645        let config = TempConfig::echo("Mute", 8128);
646        cp.deploy(prepared(&cp, &config), config.path.clone())
647            .await
648            .expect("deploy");
649
650        let Recovered::Adopted(agents) = cp.recover().await.expect("recover") else {
651            panic!("expected Adopted");
652        };
653        assert_eq!(agents.len(), 1, "adopted despite the failed card fetch");
654        // `deploy` registered it from the config; recovery could not refresh it,
655        // and must not have removed it either.
656        assert!(
657            registry
658                .get(&AgentId::from("mute"))
659                .await
660                .unwrap()
661                .is_some()
662        );
663        cp.undeploy(&AgentId::from("mute"))
664            .await
665            .expect("still manageable");
666    }
667
668    /// An ephemeral runtime must not be reported as an empty fleet: "nothing is
669    /// running" and "I cannot tell what is running" are different answers.
670    #[tokio::test]
671    async fn an_ephemeral_runtime_reports_ephemeral_not_empty() {
672        let cp = ControlPlane::new(
673            Arc::new(LocalProcessRuntime::with_exe("a2a")),
674            Arc::new(InMemoryAgentRegistry::new()),
675            Arc::new(InMemoryCardSource::new()),
676        );
677        assert_eq!(cp.recover().await.unwrap(), Recovered::Ephemeral);
678    }
679
680    /// The silent-wrong this check exists to remove: the second agent loses the
681    /// bind race, but the card probe hits the *first* agent's endpoint and calls
682    /// the deploy healthy. Only the control plane can see the live fleet, so
683    /// `up`'s pre-flight rule has to run here too.
684    #[tokio::test]
685    async fn deploying_onto_a_held_port_is_rejected() {
686        let (cp, registry) = control_plane();
687        let first = TempConfig::echo("First Up", 8130);
688        let second = TempConfig::echo("Second Up", 8130);
689
690        cp.deploy(prepared(&cp, &first), first.path.clone())
691            .await
692            .expect("the first agent takes the port");
693
694        let err = cp
695            .deploy(prepared(&cp, &second), second.path.clone())
696            .await
697            .expect_err("the second must not be reported as deployed");
698        assert!(
699            matches!(&err, ControlPlaneError::PortInUse { port: 8130, holder } if holder == "first-up"),
700            "got: {err:?}"
701        );
702
703        // Rejected *before* provisioning: nothing half-deployed is left behind.
704        assert_eq!(cp.list(ListFilter::All).await.unwrap().len(), 1);
705        assert!(
706            registry
707                .get(&AgentId::from("second-up"))
708                .await
709                .unwrap()
710                .is_none()
711        );
712    }
713
714    /// Stopping the holder frees the port, even though its entry is still listed.
715    #[tokio::test]
716    async fn a_stopped_agent_does_not_hold_its_port() {
717        let (cp, _registry) = control_plane();
718        let first = TempConfig::echo("Vacating", 8131);
719        let second = TempConfig::echo("Moving In", 8131);
720
721        cp.deploy(prepared(&cp, &first), first.path.clone())
722            .await
723            .expect("deploy");
724        cp.undeploy(&AgentId::from("vacating"))
725            .await
726            .expect("undeploy");
727
728        cp.deploy(prepared(&cp, &second), second.path.clone())
729            .await
730            .expect("the port is free once its holder is stopped");
731    }
732
733    /// `http_port = 0` is "serve no HTTP", so any number of such agents coexist —
734    /// the same exemption `fleet_conflicts` makes.
735    #[tokio::test]
736    async fn port_zero_agents_never_conflict() {
737        let (cp, _registry) = control_plane();
738        for name in ["Mcp One", "Mcp Two"] {
739            let raw = format!(
740                r#"
741[agent]
742name = "{name}"
743
744[handler]
745type = "echo"
746
747[server]
748host = "127.0.0.1"
749http_port = 0
750
751[features.mcp_server]
752enabled = true
753"#
754            );
755            let prepared = cp.prepare(&raw).expect("prepare");
756            cp.deploy(prepared, PathBuf::from("unused.toml"))
757                .await
758                .expect("port 0 never collides");
759        }
760        assert_eq!(cp.list(ListFilter::All).await.unwrap().len(), 2);
761    }
762
763    #[test]
764    fn endpoint_ports_are_read_off_the_url() {
765        assert_eq!(endpoint_port("http://127.0.0.1:9090"), Some(9090));
766        assert_eq!(endpoint_port("http://0.0.0.0:9090/"), Some(9090));
767        assert_eq!(endpoint_port("http://[::1]:9090"), Some(9090));
768        // No port to contend for, so nothing to compare against.
769        assert_eq!(endpoint_port("http://agent.internal"), None);
770    }
771
772    #[tokio::test]
773    async fn undeploy_unknown_agent_errors_not_found() {
774        let (cp, _registry) = control_plane();
775        let err = cp
776            .undeploy(&AgentId::from("ghost"))
777            .await
778            .expect_err("undeploy of an unprovisioned agent should fail");
779        assert!(matches!(
780            err,
781            ControlPlaneError::Runtime(RuntimeError::NotFound(_))
782        ));
783    }
784}