lightscale-admin/backend/src/api/control_planes.rs
2026-02-13 17:07:42 +09:00

293 lines
8.2 KiB
Rust

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<String>,
has_admin_token: bool,
created_at: OffsetDateTime,
updated_at: OffsetDateTime,
}
#[derive(Debug, Deserialize)]
struct CreateControlPlaneRequest {
name: String,
base_url: String,
admin_token: Option<String>,
region: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UpdateControlPlaneRequest {
name: Option<String>,
base_url: Option<String>,
admin_token: Option<String>,
clear_admin_token: Option<bool>,
region: Option<String>,
}
#[derive(Debug, Serialize)]
struct VerifyResponse {
ok: bool,
status: Option<u16>,
body: Option<String>,
}
pub fn router() -> Router<crate::app_state::AppState> {
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<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
) -> Result<Json<Vec<ControlPlaneSummary>>, 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<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Json(req): Json<CreateControlPlaneRequest>,
) -> Result<Json<ControlPlaneSummary>, 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<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<ControlPlaneSummary>, 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<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateControlPlaneRequest>,
) -> Result<Json<ControlPlaneSummary>, 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<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, 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<crate::app_state::AppState>,
AuthUser { user }: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<VerifyResponse>, 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<ControlPlane, ApiError> {
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)
}