mod auth;
mod client;
mod http;
mod wire;
pub use auth::ControlPlaneAuth;
pub use client::{ControlPlaneClient, ControlPlaneClientError};
pub use http::control_plane_router;
pub use wire::{AgentLogs, AgentStatus, ApiErrorBody, DeployRequest, ListQuery, LogsQuery};
use std::path::PathBuf;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::warn;
use crate::core::AgentBuilder;
use crate::core::config::ConfigError;
use crate::registry::{AgentId, AgentRegistry, CardSource, RegistryError};
use crate::runtime::{
AgentRuntime, AgentSpec, EnvAllowlist, Recovered, RuntimeError, RuntimeHealth,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeployedAgent {
pub id: String,
pub endpoint: String,
pub health: RuntimeHealth,
}
#[derive(Debug, Error)]
pub enum ControlPlaneError {
#[error(transparent)]
Runtime(#[from] RuntimeError),
#[error(transparent)]
Registry(#[from] RegistryError),
#[error("invalid agent config: {0}")]
Config(#[from] ConfigError),
#[error("could not build agent card: {0}")]
Card(String),
#[error(
"port {port} is already claimed by agent '{holder}' — change this agent's \
`[server] http_port`, or stop '{holder}' first"
)]
PortInUse {
port: u16,
holder: String,
},
}
pub struct PreparedDeploy {
builder: AgentBuilder,
id: AgentId,
}
impl PreparedDeploy {
pub fn id(&self) -> &AgentId {
&self.id
}
}
impl std::fmt::Debug for PreparedDeploy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedDeploy")
.field("id", &self.id)
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub struct ControlPlane {
runtime: Arc<dyn AgentRuntime>,
registry: Arc<dyn AgentRegistry>,
cards: Arc<dyn CardSource>,
allowed_env: EnvAllowlist,
}
impl ControlPlane {
pub fn new(
runtime: Arc<dyn AgentRuntime>,
registry: Arc<dyn AgentRegistry>,
cards: Arc<dyn CardSource>,
) -> Self {
Self {
runtime,
registry,
cards,
allowed_env: EnvAllowlist::deny_all(),
}
}
pub fn with_allowed_env(mut self, allowed: EnvAllowlist) -> Self {
self.allowed_env = allowed;
self
}
pub fn prepare(&self, raw_toml: &str) -> Result<PreparedDeploy, ControlPlaneError> {
self.allowed_env.check(raw_toml)?;
let builder = AgentBuilder::from_toml(raw_toml)?;
let id = AgentId::from_name(&builder.config().agent.name);
Ok(PreparedDeploy { builder, id })
}
pub async fn deploy(
&self,
prepared: PreparedDeploy,
config_path: PathBuf,
) -> Result<DeployedAgent, ControlPlaneError> {
let PreparedDeploy { builder, id } = prepared;
let config = builder.config();
let spec = AgentSpec {
id,
config_path,
endpoint: config.agent_url(),
image: config.image().map(str::to_string),
};
let endpoint = spec.endpoint.clone();
self.check_port_free(&spec).await?;
let card = builder
.agent_card()
.await
.map_err(|e| ControlPlaneError::Card(e.to_string()))?;
let id = self.runtime.provision(spec).await?;
self.runtime.start(&id).await?;
self.registry.register(card, endpoint.clone()).await?;
let health = self.runtime.health(&id).await?;
Ok(DeployedAgent {
id: id.to_string(),
endpoint,
health,
})
}
async fn check_port_free(&self, spec: &AgentSpec) -> Result<(), ControlPlaneError> {
let Some(port) = endpoint_port(&spec.endpoint).filter(|port| *port != 0) else {
return Ok(());
};
for status in self.runtime.list().await? {
if status.id != spec.id
&& status.health != RuntimeHealth::Stopped
&& endpoint_port(&status.endpoint) == Some(port)
{
return Err(ControlPlaneError::PortInUse {
port,
holder: status.id.to_string(),
});
}
}
Ok(())
}
pub async fn recover(&self) -> Result<Recovered<DeployedAgent>, ControlPlaneError> {
let adopted: std::collections::HashSet<AgentId> = match self.runtime.recover().await? {
Recovered::Ephemeral => return Ok(Recovered::Ephemeral),
Recovered::Adopted(ids) => ids.into_iter().collect(),
};
let mut recovered = Vec::with_capacity(adopted.len());
for status in self.runtime.list().await? {
if !adopted.contains(&status.id) {
continue;
}
if status.health == RuntimeHealth::Healthy {
match self.cards.fetch(&status.endpoint).await {
Ok(card) => {
self.registry
.register(card, status.endpoint.clone())
.await?;
}
Err(e) => warn!("recovered '{}' but could not register it: {e}", status.id),
}
} else {
warn!(
"recovered '{}' as {:?}; not registered for discovery until it answers",
status.id, status.health
);
}
recovered.push(DeployedAgent {
id: status.id.to_string(),
endpoint: status.endpoint,
health: status.health,
});
}
Ok(Recovered::Adopted(recovered))
}
pub async fn undeploy(&self, id: &AgentId) -> Result<(), ControlPlaneError> {
self.runtime.stop(id).await?;
match self.registry.deregister(id).await {
Ok(()) | Err(RegistryError::NotFound(_)) => Ok(()),
}
}
pub async fn status(&self, id: &AgentId) -> Result<RuntimeHealth, ControlPlaneError> {
Ok(self.runtime.health(id).await?)
}
pub async fn logs(
&self,
id: &AgentId,
tail: Option<usize>,
) -> Result<Vec<String>, ControlPlaneError> {
Ok(self.runtime.logs(id, tail).await?)
}
pub async fn list(&self, filter: ListFilter) -> Result<Vec<DeployedAgent>, ControlPlaneError> {
let mut agents: Vec<DeployedAgent> = self
.runtime
.list()
.await?
.into_iter()
.filter(|s| filter.includes(s.health))
.map(|s| DeployedAgent {
id: s.id.to_string(),
endpoint: s.endpoint,
health: s.health,
})
.collect();
agents.sort_by(|a, b| a.id.cmp(&b.id));
Ok(agents)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ListFilter {
#[default]
Live,
All,
}
impl ListFilter {
fn includes(self, health: RuntimeHealth) -> bool {
self == ListFilter::All || health != RuntimeHealth::Stopped
}
}
fn endpoint_port(endpoint: &str) -> Option<u16> {
endpoint
.rsplit(':')
.next()?
.trim_end_matches('/')
.parse()
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::{InMemoryAgentRegistry, InMemoryCardSource};
use crate::runtime::{InMemoryAgentRuntime, LocalProcessRuntime};
use a2a_rs::domain::AgentCard;
struct TempConfig {
path: std::path::PathBuf,
}
impl TempConfig {
fn echo(name: &str, port: u16) -> Self {
let path = std::env::temp_dir()
.join(format!("cp_test_{}_{port}.toml", AgentId::from_name(name)));
let toml = format!(
r#"
[agent]
name = "{name}"
[handler]
type = "echo"
[server]
host = "127.0.0.1"
http_port = {port}
[[skills]]
id = "echo-skill"
name = "Echo"
"#
);
std::fs::write(&path, toml).unwrap();
Self { path }
}
}
impl Drop for TempConfig {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn control_plane() -> (ControlPlane, Arc<dyn AgentRegistry>) {
let registry: Arc<dyn AgentRegistry> = Arc::new(InMemoryAgentRegistry::new());
let runtime: Arc<dyn AgentRuntime> = Arc::new(InMemoryAgentRuntime::new());
let cards = Arc::new(InMemoryCardSource::new());
(
ControlPlane::new(runtime, registry.clone(), cards),
registry,
)
}
fn card(name: &str) -> AgentCard {
AgentCard {
name: name.to_string(),
..Default::default()
}
}
fn prepared(cp: &ControlPlane, config: &TempConfig) -> PreparedDeploy {
let raw = std::fs::read_to_string(&config.path).unwrap();
cp.prepare(&raw).expect("prepare")
}
#[tokio::test]
async fn deploy_runs_and_registers_then_undeploy_tears_down() {
let (cp, registry) = control_plane();
let config = TempConfig::echo("Deploy Me", 8123);
let deployed = cp
.deploy(prepared(&cp, &config), config.path.clone())
.await
.expect("deploy");
assert_eq!(deployed.id, "deploy-me");
assert_eq!(deployed.endpoint, "http://127.0.0.1:8123");
assert_eq!(deployed.health, RuntimeHealth::Healthy);
let id = AgentId::from_name("Deploy Me");
assert_eq!(cp.status(&id).await.unwrap(), RuntimeHealth::Healthy);
assert!(registry.get(&id).await.unwrap().is_some());
assert_eq!(cp.list(ListFilter::All).await.unwrap().len(), 1);
cp.undeploy(&id).await.expect("undeploy");
assert_eq!(cp.status(&id).await.unwrap(), RuntimeHealth::Stopped);
assert!(registry.get(&id).await.unwrap().is_none());
}
#[tokio::test]
async fn deploy_is_discoverable_by_skill() {
let (cp, registry) = control_plane();
let config = TempConfig::echo("Skilled Agent", 8124);
cp.deploy(prepared(&cp, &config), config.path.clone())
.await
.expect("deploy");
let matches = registry.find_by_skill("echo-skill").await.unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].card.name, "Skilled Agent");
}
#[tokio::test]
async fn prepare_rejects_env_refs_outside_the_allowlist() {
let (cp, _registry) = control_plane();
let raw = r#"
[agent]
name = "Leaky"
description = "${A2A_CP_TEST_SECRET}"
[handler]
type = "echo"
[server]
http_port = 8125
"#;
let err = cp.prepare(raw).expect_err("deny-all must reject the ref");
assert!(
matches!(&err, ControlPlaneError::Runtime(RuntimeError::DisallowedEnv(v))
if v.contains("A2A_CP_TEST_SECRET")),
"got: {err}"
);
let permissive = ControlPlane::new(
Arc::new(InMemoryAgentRuntime::new()),
Arc::new(InMemoryAgentRegistry::new()),
Arc::new(InMemoryCardSource::new()),
)
.with_allowed_env(EnvAllowlist::new(["A2A_CP_TEST_SECRET"]));
assert!(
matches!(
permissive.prepare(raw),
Err(ControlPlaneError::Config(_)) | Ok(_)
),
"an allow-listed var must clear the policy gate"
);
}
#[tokio::test]
async fn recover_readopts_running_agents_and_rebuilds_discovery() {
let runtime: Arc<dyn AgentRuntime> = Arc::new(InMemoryAgentRuntime::new());
let cards = Arc::new(InMemoryCardSource::new());
let config = TempConfig::echo("Survivor", 8126);
let endpoint = "http://127.0.0.1:8126";
cards.insert(endpoint, card("Survivor")).await;
let before = ControlPlane::new(
runtime.clone(),
Arc::new(InMemoryAgentRegistry::new()),
cards.clone(),
);
before
.deploy(prepared(&before, &config), config.path.clone())
.await
.expect("deploy");
let registry: Arc<dyn AgentRegistry> = Arc::new(InMemoryAgentRegistry::new());
let after = ControlPlane::new(runtime, registry.clone(), cards);
let id = AgentId::from_name("Survivor");
assert!(
after.list(ListFilter::All).await.unwrap().len() == 1,
"the runtime half survives on its own"
);
assert!(
registry.get(&id).await.unwrap().is_none(),
"…but discovery does not, which is what recovery has to fix"
);
let recovered = after.recover().await.expect("recover");
let Recovered::Adopted(agents) = recovered else {
panic!("a durable runtime must report Adopted, not Ephemeral");
};
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].id, "survivor");
assert_eq!(agents[0].endpoint, endpoint);
assert_eq!(agents[0].health, RuntimeHealth::Healthy);
assert!(
registry.get(&id).await.unwrap().is_some(),
"a recovered agent must be discoverable again"
);
after.undeploy(&id).await.expect("undeploy after recovery");
assert_eq!(after.status(&id).await.unwrap(), RuntimeHealth::Stopped);
}
#[tokio::test]
async fn recover_is_idempotent_and_fine_with_nothing_to_adopt() {
let (cp, registry) = control_plane();
assert_eq!(cp.recover().await.unwrap(), Recovered::Adopted(vec![]));
let config = TempConfig::echo("Twice", 8127);
cp.deploy(prepared(&cp, &config), config.path.clone())
.await
.expect("deploy");
cp.recover().await.expect("first recover");
cp.recover().await.expect("second recover");
assert_eq!(cp.list(ListFilter::All).await.unwrap().len(), 1);
assert_eq!(registry.list().await.unwrap().len(), 1);
}
#[tokio::test]
async fn an_unreachable_agent_is_adopted_but_not_registered() {
let cards = Arc::new(InMemoryCardSource::new());
let registry: Arc<dyn AgentRegistry> = Arc::new(InMemoryAgentRegistry::new());
let cp = ControlPlane::new(
Arc::new(InMemoryAgentRuntime::new()),
registry.clone(),
cards,
);
let config = TempConfig::echo("Mute", 8128);
cp.deploy(prepared(&cp, &config), config.path.clone())
.await
.expect("deploy");
let Recovered::Adopted(agents) = cp.recover().await.expect("recover") else {
panic!("expected Adopted");
};
assert_eq!(agents.len(), 1, "adopted despite the failed card fetch");
assert!(
registry
.get(&AgentId::from("mute"))
.await
.unwrap()
.is_some()
);
cp.undeploy(&AgentId::from("mute"))
.await
.expect("still manageable");
}
#[tokio::test]
async fn an_ephemeral_runtime_reports_ephemeral_not_empty() {
let cp = ControlPlane::new(
Arc::new(LocalProcessRuntime::with_exe("a2a")),
Arc::new(InMemoryAgentRegistry::new()),
Arc::new(InMemoryCardSource::new()),
);
assert_eq!(cp.recover().await.unwrap(), Recovered::Ephemeral);
}
#[tokio::test]
async fn deploying_onto_a_held_port_is_rejected() {
let (cp, registry) = control_plane();
let first = TempConfig::echo("First Up", 8130);
let second = TempConfig::echo("Second Up", 8130);
cp.deploy(prepared(&cp, &first), first.path.clone())
.await
.expect("the first agent takes the port");
let err = cp
.deploy(prepared(&cp, &second), second.path.clone())
.await
.expect_err("the second must not be reported as deployed");
assert!(
matches!(&err, ControlPlaneError::PortInUse { port: 8130, holder } if holder == "first-up"),
"got: {err:?}"
);
assert_eq!(cp.list(ListFilter::All).await.unwrap().len(), 1);
assert!(
registry
.get(&AgentId::from("second-up"))
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn a_stopped_agent_does_not_hold_its_port() {
let (cp, _registry) = control_plane();
let first = TempConfig::echo("Vacating", 8131);
let second = TempConfig::echo("Moving In", 8131);
cp.deploy(prepared(&cp, &first), first.path.clone())
.await
.expect("deploy");
cp.undeploy(&AgentId::from("vacating"))
.await
.expect("undeploy");
cp.deploy(prepared(&cp, &second), second.path.clone())
.await
.expect("the port is free once its holder is stopped");
}
#[tokio::test]
async fn port_zero_agents_never_conflict() {
let (cp, _registry) = control_plane();
for name in ["Mcp One", "Mcp Two"] {
let raw = format!(
r#"
[agent]
name = "{name}"
[handler]
type = "echo"
[server]
host = "127.0.0.1"
http_port = 0
[features.mcp_server]
enabled = true
"#
);
let prepared = cp.prepare(&raw).expect("prepare");
cp.deploy(prepared, PathBuf::from("unused.toml"))
.await
.expect("port 0 never collides");
}
assert_eq!(cp.list(ListFilter::All).await.unwrap().len(), 2);
}
#[test]
fn endpoint_ports_are_read_off_the_url() {
assert_eq!(endpoint_port("http://127.0.0.1:9090"), Some(9090));
assert_eq!(endpoint_port("http://0.0.0.0:9090/"), Some(9090));
assert_eq!(endpoint_port("http://[::1]:9090"), Some(9090));
assert_eq!(endpoint_port("http://agent.internal"), None);
}
#[tokio::test]
async fn undeploy_unknown_agent_errors_not_found() {
let (cp, _registry) = control_plane();
let err = cp
.undeploy(&AgentId::from("ghost"))
.await
.expect_err("undeploy of an unprovisioned agent should fail");
assert!(matches!(
err,
ControlPlaneError::Runtime(RuntimeError::NotFound(_))
));
}
}