use std::sync::{Arc, Mutex};
use axum::extract::State;
use axum::http::header::CONTENT_TYPE;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use hydracache_actuator_axum::HydraCacheActuator;
use hydracache_client_transport_axum::{
HYDRACACHE_ADMIN_HEADER, HYDRACACHE_CLIENT_ID_HEADER, HYDRACACHE_TENANT_HEADER,
};
use serde::Serialize;
use thiserror::Error;
use crate::bootstrap::{ServerAdminActionError, ServerRuntime};
use crate::cluster_status::RaftCompactionError;
use crate::hc2::Hc2ClientPlaneService;
use crate::services::DrainOutcome;
use hydracache_observability::PrometheusExporter;
pub const ADMIN_HEALTHZ_PATH: &str = "/healthz";
pub const ADMIN_READYZ_PATH: &str = "/readyz";
pub const ADMIN_METRICS_PATH: &str = "/metrics";
pub const ADMIN_CONSOLE_PATH: &str = "/console";
pub const ADMIN_CLUSTER_OVERVIEW_PATH: &str = "/cluster/overview";
pub const ADMIN_ACTUATOR_PATH: &str = "/actuator/hydracache";
pub const ADMIN_STATUS_PATH: &str = "/admin/status";
pub const ADMIN_DRAIN_PATH: &str = "/admin/drain";
pub const ADMIN_RESHARD_PATH: &str = "/admin/reshard";
pub const ADMIN_BACKUP_PATH: &str = "/admin/backup";
pub const ADMIN_RAFT_COMPACTION_PATH: &str = "/admin/raft/compaction";
pub type SharedServerRuntime = Arc<Mutex<ServerRuntime>>;
#[derive(Debug, Clone)]
pub struct AdminHttpSurface {
runtime: SharedServerRuntime,
hc2_metrics: Option<Hc2ClientPlaneService>,
}
impl AdminHttpSurface {
pub fn new(runtime: ServerRuntime) -> Self {
Self {
runtime: Arc::new(Mutex::new(runtime)),
hc2_metrics: None,
}
}
pub fn from_shared(runtime: SharedServerRuntime) -> Self {
Self {
runtime,
hc2_metrics: None,
}
}
pub fn with_hc2_metrics(mut self, service: Hc2ClientPlaneService) -> Self {
self.hc2_metrics = Some(service);
self
}
pub fn runtime(&self) -> SharedServerRuntime {
Arc::clone(&self.runtime)
}
pub fn routes(&self) -> Router {
let actuator_registry = self
.runtime
.lock()
.expect("server runtime mutex")
.metrics_registry();
let routes = Router::new()
.route(ADMIN_HEALTHZ_PATH, get(healthz))
.route(ADMIN_READYZ_PATH, get(readyz))
.route(ADMIN_METRICS_PATH, get(metrics))
.route(ADMIN_CONSOLE_PATH, get(console_index))
.route("/console/", get(console_index))
.route("/console/index.html", get(console_index))
.route("/console/app.js", get(console_app))
.route("/console/style.css", get(console_style))
.route(ADMIN_CLUSTER_OVERVIEW_PATH, get(cluster_overview))
.route(ADMIN_STATUS_PATH, get(admin_status))
.route(ADMIN_DRAIN_PATH, get(admin_drain).post(admin_drain))
.route(ADMIN_RESHARD_PATH, post(admin_reshard))
.route(ADMIN_BACKUP_PATH, post(admin_backup))
.route(
ADMIN_RAFT_COMPACTION_PATH,
get(admin_raft_compaction_status).post(admin_raft_compaction),
)
.with_state(Arc::clone(&self.runtime))
.nest(
ADMIN_ACTUATOR_PATH,
HydraCacheActuator::routes_for(actuator_registry),
);
if let Some(service) = self.hc2_metrics.clone() {
routes.layer(Extension(service))
} else {
routes
}
}
}
async fn healthz(State(runtime): State<SharedServerRuntime>) -> Response {
let health = runtime.lock().expect("server runtime mutex").health();
(StatusCode::OK, Json(health)).into_response()
}
async fn readyz(State(runtime): State<SharedServerRuntime>) -> Response {
let ready = runtime.lock().expect("server runtime mutex").ready();
let status = if ready.ready {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
};
(status, Json(ready)).into_response()
}
async fn metrics(
State(runtime): State<SharedServerRuntime>,
hc2: Option<Extension<Hc2ClientPlaneService>>,
) -> Response {
let registry = runtime
.lock()
.expect("server runtime mutex")
.metrics_registry();
let mut text = PrometheusExporter::new(registry).render().await;
if let Some(Extension(service)) = hc2 {
text.push_str(&service.prometheus_metrics());
}
([(CONTENT_TYPE, "text/plain; version=0.0.4")], text).into_response()
}
async fn console_index() -> Response {
(
[(CONTENT_TYPE, "text/html; charset=utf-8")],
include_str!("../console/index.html"),
)
.into_response()
}
async fn console_app() -> Response {
(
[(CONTENT_TYPE, "text/javascript; charset=utf-8")],
include_str!("../console/app.js"),
)
.into_response()
}
async fn console_style() -> Response {
(
[(CONTENT_TYPE, "text/css; charset=utf-8")],
include_str!("../console/style.css"),
)
.into_response()
}
async fn cluster_overview(State(runtime): State<SharedServerRuntime>) -> Response {
let overview = runtime
.lock()
.expect("server runtime mutex")
.cluster_overview();
(StatusCode::OK, Json(overview)).into_response()
}
async fn admin_status(State(runtime): State<SharedServerRuntime>, headers: HeaderMap) -> Response {
if let Err(error) = require_admin(&headers) {
return error.into_response();
}
let status = runtime.lock().expect("server runtime mutex").admin_status();
(StatusCode::OK, Json(status)).into_response()
}
async fn admin_drain(State(runtime): State<SharedServerRuntime>, headers: HeaderMap) -> Response {
if let Err(error) = require_admin(&headers) {
return error.into_response();
}
let drain = runtime
.lock()
.expect("server runtime mutex")
.request_admin_drain();
(
StatusCode::OK,
Json(AdminDrainReply {
action: "drain",
outcome: "accepted",
drain,
}),
)
.into_response()
}
async fn admin_reshard(State(runtime): State<SharedServerRuntime>, headers: HeaderMap) -> Response {
if let Err(error) = require_admin(&headers) {
return error.into_response();
}
runtime
.lock()
.expect("server runtime mutex")
.request_reshard()
.map(|action| (StatusCode::OK, Json(action)).into_response())
.unwrap_or_else(|error| AdminHttpError::from(error).into_response())
}
async fn admin_backup(State(runtime): State<SharedServerRuntime>, headers: HeaderMap) -> Response {
if let Err(error) = require_admin(&headers) {
return error.into_response();
}
runtime
.lock()
.expect("server runtime mutex")
.request_backup()
.map(|action| {
(
StatusCode::ACCEPTED,
Json(AdminBackupRequestAcceptance {
action: action.action,
outcome: action.outcome,
detail: action.detail,
authority: "request_only",
durable_artifact_created: false,
restore_point_available: false,
}),
)
.into_response()
})
.unwrap_or_else(|error| AdminHttpError::from(error).into_response())
}
async fn admin_raft_compaction_status(
State(runtime): State<SharedServerRuntime>,
headers: HeaderMap,
) -> Response {
if let Err(error) = require_admin(&headers) {
return error.into_response();
}
runtime
.lock()
.expect("server runtime mutex")
.raft_compaction_status()
.map(|status| (StatusCode::OK, Json(status)).into_response())
.unwrap_or_else(|error| {
AdminHttpError::from(ServerAdminActionError::from(error)).into_response()
})
}
async fn admin_raft_compaction(
State(runtime): State<SharedServerRuntime>,
headers: HeaderMap,
) -> Response {
if let Err(error) = require_admin(&headers) {
return error.into_response();
}
runtime
.lock()
.expect("server runtime mutex")
.request_raft_compaction()
.map(|status| (StatusCode::OK, Json(status)).into_response())
.unwrap_or_else(|error| AdminHttpError::from(error).into_response())
}
#[derive(Debug, Serialize)]
struct AdminBackupRequestAcceptance {
action: &'static str,
outcome: &'static str,
detail: String,
authority: &'static str,
durable_artifact_created: bool,
restore_point_available: bool,
}
fn require_admin(headers: &HeaderMap) -> Result<(), AdminHttpError> {
let has_identity = header_value(headers, HYDRACACHE_CLIENT_ID_HEADER).is_some()
&& header_value(headers, HYDRACACHE_TENANT_HEADER).is_some();
if !has_identity {
return Err(AdminHttpError::Unauthenticated);
}
let admin = headers
.get(HYDRACACHE_ADMIN_HEADER)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| matches!(value, "true" | "1"));
if !admin {
return Err(AdminHttpError::Unauthorized);
}
Ok(())
}
fn header_value(headers: &HeaderMap, name: &'static str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AdminDrainReply {
pub action: &'static str,
pub outcome: &'static str,
pub drain: DrainOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AdminErrorReply {
pub outcome: &'static str,
pub detail: String,
}
impl AdminErrorReply {
fn rejected(detail: impl Into<String>) -> Self {
Self {
outcome: "rejected",
detail: detail.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum AdminHttpError {
#[error("admin identity is required")]
Unauthenticated,
#[error("admin privileges are required")]
Unauthorized,
#[error("{0}")]
Action(#[from] ServerAdminActionError),
}
impl IntoResponse for AdminHttpError {
fn into_response(self) -> Response {
let status = match self {
Self::Unauthenticated => StatusCode::UNAUTHORIZED,
Self::Unauthorized => StatusCode::FORBIDDEN,
Self::Action(ServerAdminActionError::NotReady(_)) => StatusCode::SERVICE_UNAVAILABLE,
Self::Action(
ServerAdminActionError::RequiresMember(_) | ServerAdminActionError::BackupDisabled,
) => StatusCode::CONFLICT,
Self::Action(ServerAdminActionError::RaftCompaction(
RaftCompactionError::Disabled | RaftCompactionError::Unavailable,
)) => StatusCode::CONFLICT,
Self::Action(ServerAdminActionError::RaftCompaction(RaftCompactionError::Runtime(
_,
))) => StatusCode::INTERNAL_SERVER_ERROR,
};
(status, Json(AdminErrorReply::rejected(self.to_string()))).into_response()
}
}