use crate::api::{ApiError, AuthUser}; use crate::audit_log; use crate::models::ControlPlane; use crate::permissions::{ ensure_permission_global, PERM_CONTROL_PLANES_READ, PERM_CONTROL_PLANES_WRITE, }; use axum::extract::{Path, State}; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use time::OffsetDateTime; use uuid::Uuid; #[derive(Debug, Serialize)] struct ControlPlaneSummary { id: Uuid, name: String, base_url: String, region: Option, has_admin_token: bool, created_at: OffsetDateTime, updated_at: OffsetDateTime, } #[derive(Debug, Deserialize)] struct CreateControlPlaneRequest { name: String, base_url: String, admin_token: Option, region: Option, } #[derive(Debug, Deserialize)] struct UpdateControlPlaneRequest { name: Option, base_url: Option, admin_token: Option, clear_admin_token: Option, region: Option, } #[derive(Debug, Serialize)] struct VerifyResponse { ok: bool, status: Option, body: Option, } pub fn router() -> Router { Router::new() .route("/", get(list_control_planes).post(create_control_plane)) .route( "/:id", get(get_control_plane) .put(update_control_plane) .delete(delete_control_plane), ) .route("/:id/verify", post(verify_control_plane)) } async fn list_control_planes( State(state): State, AuthUser { user }: AuthUser, ) -> Result>, ApiError> { ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_READ).await?; let rows = sqlx::query_as::<_, ControlPlane>( r#" SELECT id, name, base_url, admin_token, region, created_at, updated_at FROM control_planes ORDER BY name "#, ) .fetch_all(&state.pool) .await .map_err(|_| ApiError::Internal)?; let summary = rows .into_iter() .map(|row| ControlPlaneSummary { id: row.id, name: row.name, base_url: row.base_url, region: row.region, has_admin_token: row.admin_token.is_some(), created_at: row.created_at, updated_at: row.updated_at, }) .collect(); Ok(Json(summary)) } async fn create_control_plane( State(state): State, AuthUser { user }: AuthUser, Json(req): Json, ) -> Result, ApiError> { ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_WRITE).await?; let now = OffsetDateTime::now_utc(); let base_url = req.base_url.trim_end_matches('/').to_string(); let record = sqlx::query_as::<_, ControlPlane>( r#" INSERT INTO control_planes (id, name, base_url, admin_token, region, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $6) RETURNING id, name, base_url, admin_token, region, created_at, updated_at "#, ) .bind(Uuid::new_v4()) .bind(req.name) .bind(base_url) .bind(req.admin_token) .bind(req.region) .bind(now) .fetch_one(&state.pool) .await .map_err(|_| ApiError::Internal)?; audit_log::record( &state.pool, Some(user.id), "control_plane.create", Some("control_plane"), Some(&record.id.to_string()), None, ) .await?; Ok(Json(ControlPlaneSummary { id: record.id, name: record.name, base_url: record.base_url, region: record.region, has_admin_token: record.admin_token.is_some(), created_at: record.created_at, updated_at: record.updated_at, })) } async fn get_control_plane( State(state): State, AuthUser { user }: AuthUser, Path(id): Path, ) -> Result, ApiError> { ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_READ).await?; let record = fetch_control_plane(&state.pool, id).await?; Ok(Json(ControlPlaneSummary { id: record.id, name: record.name, base_url: record.base_url, region: record.region, has_admin_token: record.admin_token.is_some(), created_at: record.created_at, updated_at: record.updated_at, })) } async fn update_control_plane( State(state): State, AuthUser { user }: AuthUser, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_WRITE).await?; let existing = fetch_control_plane(&state.pool, id).await?; let name = req.name.unwrap_or(existing.name); let base_url = req .base_url .map(|value| value.trim_end_matches('/').to_string()) .unwrap_or(existing.base_url); let admin_token = if req.clear_admin_token.unwrap_or(false) { None } else { req.admin_token.or(existing.admin_token) }; let region = req.region.or(existing.region); let now = OffsetDateTime::now_utc(); let record = sqlx::query_as::<_, ControlPlane>( r#" UPDATE control_planes SET name = $2, base_url = $3, admin_token = $4, region = $5, updated_at = $6 WHERE id = $1 RETURNING id, name, base_url, admin_token, region, created_at, updated_at "#, ) .bind(id) .bind(name) .bind(base_url) .bind(admin_token) .bind(region) .bind(now) .fetch_one(&state.pool) .await .map_err(|_| ApiError::Internal)?; audit_log::record( &state.pool, Some(user.id), "control_plane.update", Some("control_plane"), Some(&record.id.to_string()), None, ) .await?; Ok(Json(ControlPlaneSummary { id: record.id, name: record.name, base_url: record.base_url, region: record.region, has_admin_token: record.admin_token.is_some(), created_at: record.created_at, updated_at: record.updated_at, })) } async fn delete_control_plane( State(state): State, AuthUser { user }: AuthUser, Path(id): Path, ) -> Result, ApiError> { ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_WRITE).await?; let record = fetch_control_plane(&state.pool, id).await?; sqlx::query("DELETE FROM control_planes WHERE id = $1") .bind(id) .execute(&state.pool) .await .map_err(|_| ApiError::Internal)?; audit_log::record( &state.pool, Some(user.id), "control_plane.delete", Some("control_plane"), Some(&record.id.to_string()), None, ) .await?; Ok(Json(serde_json::json!({ "ok": true }))) } async fn verify_control_plane( State(state): State, AuthUser { user }: AuthUser, Path(id): Path, ) -> Result, ApiError> { ensure_permission_global(&state.pool, &user, PERM_CONTROL_PLANES_READ).await?; let record = fetch_control_plane(&state.pool, id).await?; let url = format!("{}/healthz", record.base_url.trim_end_matches('/')); let client = reqwest::Client::new(); let response = client.get(url).send().await; let mut out = VerifyResponse { ok: false, status: None, body: None, }; match response { Ok(resp) => { out.status = Some(resp.status().as_u16()); out.ok = resp.status().is_success(); if let Ok(text) = resp.text().await { out.body = Some(text); } } Err(_) => { out.ok = false; } } Ok(Json(out)) } async fn fetch_control_plane(pool: &PgPool, id: Uuid) -> Result { sqlx::query_as::<_, ControlPlane>( r#" SELECT id, name, base_url, admin_token, region, created_at, updated_at FROM control_planes WHERE id = $1 "#, ) .bind(id) .fetch_optional(pool) .await .map_err(|_| ApiError::Internal)? .ok_or(ApiError::NotFound) }