use std::path::PathBuf;
use std::sync::Arc;
use axum::extract::{Path as AxPath, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use super::auth::ControlPlaneAuth;
use super::wire::{AgentLogs, AgentStatus, ApiErrorBody, DeployRequest, ListQuery, LogsQuery};
use super::{ControlPlane, ControlPlaneError, DeployedAgent, ListFilter};
use crate::registry::AgentId;
use crate::runtime::RuntimeError;
#[derive(Clone)]
struct AppState {
cp: Arc<ControlPlane>,
config_dir: PathBuf,
}
pub fn control_plane_router(
cp: Arc<ControlPlane>,
config_dir: PathBuf,
auth: ControlPlaneAuth,
) -> Router {
let state = AppState { cp, config_dir };
let router = Router::new()
.route("/agents", post(deploy).get(list))
.route("/agents/:id", get(status).delete(undeploy))
.route("/agents/:id/logs", get(logs))
.with_state(state);
auth.apply(router)
}
async fn deploy(
State(state): State<AppState>,
Json(req): Json<DeployRequest>,
) -> Result<(StatusCode, Json<DeployedAgent>), ApiError> {
let prepared = state.cp.prepare(&req.config_toml)?;
tokio::fs::create_dir_all(&state.config_dir).await?;
let path = state.config_dir.join(format!("{}.toml", prepared.id()));
tokio::fs::write(&path, &req.config_toml).await?;
let deployed = state.cp.deploy(prepared, path).await?;
Ok((StatusCode::CREATED, Json(deployed)))
}
async fn list(
State(state): State<AppState>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<DeployedAgent>>, ApiError> {
let filter = if query.all {
ListFilter::All
} else {
ListFilter::Live
};
Ok(Json(state.cp.list(filter).await?))
}
async fn status(
State(state): State<AppState>,
AxPath(id): AxPath<String>,
) -> Result<Json<AgentStatus>, ApiError> {
let id = AgentId::from(id);
let health = state.cp.status(&id).await?;
Ok(Json(AgentStatus {
id: id.to_string(),
health,
}))
}
async fn logs(
State(state): State<AppState>,
AxPath(id): AxPath<String>,
Query(query): Query<LogsQuery>,
) -> Result<Json<AgentLogs>, ApiError> {
let id = AgentId::from(id);
let lines = state.cp.logs(&id, query.tail).await?;
Ok(Json(AgentLogs {
id: id.to_string(),
lines,
}))
}
async fn undeploy(
State(state): State<AppState>,
AxPath(id): AxPath<String>,
) -> Result<StatusCode, ApiError> {
state.cp.undeploy(&AgentId::from(id)).await?;
Ok(StatusCode::NO_CONTENT)
}
enum ApiError {
Domain(ControlPlaneError),
Io(std::io::Error),
}
impl From<ControlPlaneError> for ApiError {
fn from(e: ControlPlaneError) -> Self {
ApiError::Domain(e)
}
}
impl From<crate::core::config::ConfigError> for ApiError {
fn from(e: crate::core::config::ConfigError) -> Self {
ApiError::Domain(ControlPlaneError::Config(e))
}
}
impl From<std::io::Error> for ApiError {
fn from(e: std::io::Error) -> Self {
ApiError::Io(e)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match &self {
ApiError::Domain(ControlPlaneError::Runtime(RuntimeError::NotFound(_))) => {
StatusCode::NOT_FOUND
}
ApiError::Domain(
ControlPlaneError::Runtime(RuntimeError::AlreadyRunning(_))
| ControlPlaneError::PortInUse { .. },
) => StatusCode::CONFLICT,
ApiError::Domain(
ControlPlaneError::Config(_)
| ControlPlaneError::Card(_)
| ControlPlaneError::Runtime(
RuntimeError::DisallowedEnv(_) | RuntimeError::Config(_),
),
) => StatusCode::BAD_REQUEST,
ApiError::Domain(ControlPlaneError::Runtime(RuntimeError::Unsupported { .. })) => {
StatusCode::NOT_IMPLEMENTED
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
let error = match &self {
ApiError::Domain(e) => e.to_string(),
ApiError::Io(e) => e.to_string(),
};
(status, Json(ApiErrorBody { error })).into_response()
}
}