1mod 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct DeployedAgent {
52 pub id: String,
54 pub endpoint: String,
56 pub health: RuntimeHealth,
58}
59
60#[derive(Debug, Error)]
62pub enum ControlPlaneError {
63 #[error(transparent)]
65 Runtime(#[from] RuntimeError),
66
67 #[error(transparent)]
69 Registry(#[from] RegistryError),
70
71 #[error("invalid agent config: {0}")]
73 Config(#[from] ConfigError),
74
75 #[error("could not build agent card: {0}")]
77 Card(String),
78
79 #[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 port: u16,
90 holder: String,
92 },
93}
94
95pub struct PreparedDeploy {
102 builder: AgentBuilder,
103 id: AgentId,
104}
105
106impl PreparedDeploy {
107 pub fn id(&self) -> &AgentId {
109 &self.id
110 }
111}
112
113impl 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#[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 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 pub fn with_allowed_env(mut self, allowed: EnvAllowlist) -> Self {
165 self.allowed_env = allowed;
166 self
167 }
168
169 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 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 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 async fn check_port_free(&self, spec: &AgentSpec) -> Result<(), ControlPlaneError> {
247 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 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 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 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 pub async fn status(&self, id: &AgentId) -> Result<RuntimeHealth, ControlPlaneError> {
334 Ok(self.runtime.health(id).await?)
335 }
336
337 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
382pub enum ListFilter {
383 #[default]
386 Live,
387 All,
389}
390
391impl ListFilter {
392 fn includes(self, health: RuntimeHealth) -> bool {
394 self == ListFilter::All || health != RuntimeHealth::Stopped
395 }
396}
397
398fn 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 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 fn card(name: &str) -> AgentCard {
468 AgentCard {
469 name: name.to_string(),
470 ..Default::default()
471 }
472 }
473
474 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 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 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 #[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 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 #[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 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 after.undeploy(&id).await.expect("undeploy after recovery");
612 assert_eq!(after.status(&id).await.unwrap(), RuntimeHealth::Stopped);
613 }
614
615 #[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 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 #[tokio::test]
636 async fn an_unreachable_agent_is_adopted_but_not_registered() {
637 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 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 #[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 #[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 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 #[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 #[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 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}