293 lines
8.2 KiB
Rust
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)
|
|
}
|