1use std::sync::{Arc, Mutex};
2
3use axum::extract::State;
4use axum::http::header::CONTENT_TYPE;
5use axum::http::{HeaderMap, StatusCode};
6use axum::response::{IntoResponse, Response};
7use axum::routing::{get, post};
8use axum::{Extension, Json, Router};
9use hydracache_actuator_axum::HydraCacheActuator;
10use hydracache_client_transport_axum::{
11 ClientSurfaceDiagnosticReset, HYDRACACHE_ADMIN_HEADER, HYDRACACHE_CLIENT_ID_HEADER,
12 HYDRACACHE_TENANT_HEADER,
13};
14use serde::Serialize;
15use thiserror::Error;
16
17use crate::bootstrap::{ServerAdminActionError, ServerRuntime};
18use crate::cluster_status::RaftCompactionError;
19use crate::hc2::Hc2ClientPlaneService;
20use crate::services::DrainOutcome;
21use hydracache_observability::PrometheusExporter;
22
23pub const ADMIN_HEALTHZ_PATH: &str = "/healthz";
25pub const ADMIN_READYZ_PATH: &str = "/readyz";
27pub const ADMIN_METRICS_PATH: &str = "/metrics";
29pub const ADMIN_CONSOLE_PATH: &str = "/console";
31pub const ADMIN_CLUSTER_OVERVIEW_PATH: &str = "/cluster/overview";
33pub const ADMIN_ACTUATOR_PATH: &str = "/actuator/hydracache";
35pub const ADMIN_STATUS_PATH: &str = "/admin/status";
37pub const ADMIN_DRAIN_PATH: &str = "/admin/drain";
39pub const ADMIN_RESHARD_PATH: &str = "/admin/reshard";
41pub const ADMIN_BACKUP_PATH: &str = "/admin/backup";
43pub const ADMIN_RAFT_COMPACTION_PATH: &str = "/admin/raft/compaction";
45pub const ADMIN_DIAGNOSTIC_RESET_PATH: &str = "/admin/diagnostics/reset";
47
48pub type SharedServerRuntime = Arc<Mutex<ServerRuntime>>;
50
51#[derive(Debug, Clone)]
53pub struct AdminHttpSurface {
54 runtime: SharedServerRuntime,
55 hc2_metrics: Option<Hc2ClientPlaneService>,
56}
57
58impl AdminHttpSurface {
59 pub fn new(runtime: ServerRuntime) -> Self {
61 Self {
62 runtime: Arc::new(Mutex::new(runtime)),
63 hc2_metrics: None,
64 }
65 }
66
67 pub fn from_shared(runtime: SharedServerRuntime) -> Self {
69 Self {
70 runtime,
71 hc2_metrics: None,
72 }
73 }
74
75 pub fn with_hc2_metrics(mut self, service: Hc2ClientPlaneService) -> Self {
78 self.hc2_metrics = Some(service);
79 self
80 }
81
82 pub fn runtime(&self) -> SharedServerRuntime {
84 Arc::clone(&self.runtime)
85 }
86
87 pub fn routes(&self) -> Router {
89 let actuator_registry = self
90 .runtime
91 .lock()
92 .expect("server runtime mutex")
93 .metrics_registry();
94 let routes = Router::new()
95 .route(ADMIN_HEALTHZ_PATH, get(healthz))
96 .route(ADMIN_READYZ_PATH, get(readyz))
97 .route(ADMIN_METRICS_PATH, get(metrics))
98 .route(ADMIN_CONSOLE_PATH, get(console_index))
99 .route("/console/", get(console_index))
100 .route("/console/index.html", get(console_index))
101 .route("/console/app.js", get(console_app))
102 .route("/console/style.css", get(console_style))
103 .route(ADMIN_CLUSTER_OVERVIEW_PATH, get(cluster_overview))
104 .route(ADMIN_STATUS_PATH, get(admin_status))
105 .route(ADMIN_DRAIN_PATH, get(admin_drain).post(admin_drain))
106 .route(ADMIN_RESHARD_PATH, post(admin_reshard))
107 .route(ADMIN_BACKUP_PATH, post(admin_backup))
108 .route(ADMIN_DIAGNOSTIC_RESET_PATH, post(admin_diagnostic_reset))
109 .route(
110 ADMIN_RAFT_COMPACTION_PATH,
111 get(admin_raft_compaction_status).post(admin_raft_compaction),
112 )
113 .with_state(Arc::clone(&self.runtime))
114 .nest(
115 ADMIN_ACTUATOR_PATH,
116 HydraCacheActuator::routes_for(actuator_registry),
117 );
118 if let Some(service) = self.hc2_metrics.clone() {
119 routes.layer(Extension(service))
120 } else {
121 routes
122 }
123 }
124}
125
126async fn healthz(State(runtime): State<SharedServerRuntime>) -> Response {
127 let health = runtime.lock().expect("server runtime mutex").health();
128 (StatusCode::OK, Json(health)).into_response()
129}
130
131async fn readyz(State(runtime): State<SharedServerRuntime>) -> Response {
132 let ready = runtime.lock().expect("server runtime mutex").ready();
133 let status = if ready.ready {
134 StatusCode::OK
135 } else {
136 StatusCode::SERVICE_UNAVAILABLE
137 };
138 (status, Json(ready)).into_response()
139}
140
141async fn metrics(
142 State(runtime): State<SharedServerRuntime>,
143 hc2: Option<Extension<Hc2ClientPlaneService>>,
144) -> Response {
145 let registry = runtime
146 .lock()
147 .expect("server runtime mutex")
148 .metrics_registry();
149 let mut text = PrometheusExporter::new(registry).render().await;
150 if let Some(Extension(service)) = hc2 {
151 text.push_str(&service.prometheus_metrics());
152 }
153 ([(CONTENT_TYPE, "text/plain; version=0.0.4")], text).into_response()
154}
155
156async fn console_index() -> Response {
157 (
158 [(CONTENT_TYPE, "text/html; charset=utf-8")],
159 include_str!("../console/index.html"),
160 )
161 .into_response()
162}
163
164async fn console_app() -> Response {
165 (
166 [(CONTENT_TYPE, "text/javascript; charset=utf-8")],
167 include_str!("../console/app.js"),
168 )
169 .into_response()
170}
171
172async fn console_style() -> Response {
173 (
174 [(CONTENT_TYPE, "text/css; charset=utf-8")],
175 include_str!("../console/style.css"),
176 )
177 .into_response()
178}
179
180async fn cluster_overview(State(runtime): State<SharedServerRuntime>) -> Response {
181 let overview = runtime
182 .lock()
183 .expect("server runtime mutex")
184 .cluster_overview();
185 (StatusCode::OK, Json(overview)).into_response()
186}
187
188async fn admin_status(State(runtime): State<SharedServerRuntime>, headers: HeaderMap) -> Response {
189 if let Err(error) = require_admin(&headers) {
190 return error.into_response();
191 }
192 let status = runtime.lock().expect("server runtime mutex").admin_status();
193 (StatusCode::OK, Json(status)).into_response()
194}
195
196async fn admin_drain(State(runtime): State<SharedServerRuntime>, headers: HeaderMap) -> Response {
197 if let Err(error) = require_admin(&headers) {
198 return error.into_response();
199 }
200 let drain = runtime
201 .lock()
202 .expect("server runtime mutex")
203 .request_admin_drain();
204 (
205 StatusCode::OK,
206 Json(AdminDrainReply {
207 action: "drain",
208 outcome: "accepted",
209 drain,
210 }),
211 )
212 .into_response()
213}
214
215async fn admin_reshard(State(runtime): State<SharedServerRuntime>, headers: HeaderMap) -> Response {
216 if let Err(error) = require_admin(&headers) {
217 return error.into_response();
218 }
219 runtime
220 .lock()
221 .expect("server runtime mutex")
222 .request_reshard()
223 .map(|action| (StatusCode::OK, Json(action)).into_response())
224 .unwrap_or_else(|error| AdminHttpError::from(error).into_response())
225}
226
227async fn admin_backup(State(runtime): State<SharedServerRuntime>, headers: HeaderMap) -> Response {
228 if let Err(error) = require_admin(&headers) {
229 return error.into_response();
230 }
231 runtime
232 .lock()
233 .expect("server runtime mutex")
234 .request_backup()
235 .map(|action| {
236 (
237 StatusCode::ACCEPTED,
238 Json(AdminBackupRequestAcceptance {
239 action: action.action,
240 outcome: action.outcome,
241 detail: action.detail,
242 authority: "request_only",
243 durable_artifact_created: false,
244 restore_point_available: false,
245 }),
246 )
247 .into_response()
248 })
249 .unwrap_or_else(|error| AdminHttpError::from(error).into_response())
250}
251
252async fn admin_diagnostic_reset(
253 State(runtime): State<SharedServerRuntime>,
254 hc2: Option<Extension<Hc2ClientPlaneService>>,
255 headers: HeaderMap,
256) -> Response {
257 if let Err(error) = require_admin(&headers) {
258 return error.into_response();
259 }
260 let (cache, client_state) = {
261 let runtime = runtime.lock().expect("server runtime mutex");
262 if !runtime.diagnostic_reset_enabled() {
263 return AdminHttpError::DiagnosticResetDisabled.into_response();
264 }
265 runtime.diagnostic_reset_targets()
266 };
267 if client_state
268 .as_ref()
269 .is_some_and(|state| state.active_subscriptions() != 0)
270 || hc2.as_ref().is_some_and(|Extension(service)| {
271 let accounting = service.accounting();
272 accounting.active_connections != 0
273 || accounting.pending_invocations != 0
274 || accounting.active_subscriptions != 0
275 || accounting.active_sessions != 0
276 })
277 {
278 return AdminHttpError::DiagnosticResetBusy.into_response();
279 }
280
281 let embedded_before = cache.diagnostics().await.estimated_entries;
282 if let Err(error) = cache.flush().await {
283 return AdminHttpError::DiagnosticResetFailed(error.to_string()).into_response();
284 }
285 let client = match client_state
286 .map(|state| state.reset_retained_state_for_diagnostics())
287 .transpose()
288 {
289 Ok(reset) => reset,
290 Err(error) => {
291 return AdminHttpError::DiagnosticResetFailed(error.to_string()).into_response();
292 }
293 };
294 let embedded_after = cache.diagnostics().await.estimated_entries;
295 let client_is_zero = client.as_ref().is_none_or(|reset| {
296 reset.after.store_entries == 0
297 && reset.after.idempotency_outcomes == 0
298 && reset.after.conditional.records == 0
299 && reset.after.conditional.locks == 0
300 && reset.after.conditional.session_heartbeats == 0
301 });
302 if embedded_after != 0 || !client_is_zero {
303 return AdminHttpError::DiagnosticResetFailed(
304 "owner counts remained non-zero after reset".to_owned(),
305 )
306 .into_response();
307 }
308
309 (
310 StatusCode::OK,
311 Json(AdminDiagnosticResetReply {
312 action: "diagnostic_reset",
313 outcome: "completed",
314 embedded_before,
315 embedded_after,
316 client,
317 }),
318 )
319 .into_response()
320}
321
322async fn admin_raft_compaction_status(
323 State(runtime): State<SharedServerRuntime>,
324 headers: HeaderMap,
325) -> Response {
326 if let Err(error) = require_admin(&headers) {
327 return error.into_response();
328 }
329 runtime
330 .lock()
331 .expect("server runtime mutex")
332 .raft_compaction_status()
333 .map(|status| (StatusCode::OK, Json(status)).into_response())
334 .unwrap_or_else(|error| {
335 AdminHttpError::from(ServerAdminActionError::from(error)).into_response()
336 })
337}
338
339async fn admin_raft_compaction(
340 State(runtime): State<SharedServerRuntime>,
341 headers: HeaderMap,
342) -> Response {
343 if let Err(error) = require_admin(&headers) {
344 return error.into_response();
345 }
346 runtime
347 .lock()
348 .expect("server runtime mutex")
349 .request_raft_compaction()
350 .map(|status| (StatusCode::OK, Json(status)).into_response())
351 .unwrap_or_else(|error| AdminHttpError::from(error).into_response())
352}
353
354#[derive(Debug, Serialize)]
361struct AdminBackupRequestAcceptance {
362 action: &'static str,
363 outcome: &'static str,
364 detail: String,
365 authority: &'static str,
366 durable_artifact_created: bool,
367 restore_point_available: bool,
368}
369
370fn require_admin(headers: &HeaderMap) -> Result<(), AdminHttpError> {
371 let has_identity = header_value(headers, HYDRACACHE_CLIENT_ID_HEADER).is_some()
372 && header_value(headers, HYDRACACHE_TENANT_HEADER).is_some();
373 if !has_identity {
374 return Err(AdminHttpError::Unauthenticated);
375 }
376 let admin = headers
377 .get(HYDRACACHE_ADMIN_HEADER)
378 .and_then(|value| value.to_str().ok())
379 .is_some_and(|value| matches!(value, "true" | "1"));
380 if !admin {
381 return Err(AdminHttpError::Unauthorized);
382 }
383 Ok(())
384}
385
386fn header_value(headers: &HeaderMap, name: &'static str) -> Option<String> {
387 headers
388 .get(name)
389 .and_then(|value| value.to_str().ok())
390 .filter(|value| !value.trim().is_empty())
391 .map(ToOwned::to_owned)
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
396pub struct AdminDrainReply {
397 pub action: &'static str,
399 pub outcome: &'static str,
401 pub drain: DrainOutcome,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
407pub struct AdminDiagnosticResetReply {
408 pub action: &'static str,
410 pub outcome: &'static str,
412 pub embedded_before: u64,
414 pub embedded_after: u64,
416 pub client: Option<ClientSurfaceDiagnosticReset>,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
422pub struct AdminErrorReply {
423 pub outcome: &'static str,
425 pub detail: String,
427}
428
429impl AdminErrorReply {
430 fn rejected(detail: impl Into<String>) -> Self {
431 Self {
432 outcome: "rejected",
433 detail: detail.into(),
434 }
435 }
436}
437
438#[derive(Debug, Clone, PartialEq, Eq, Error)]
440pub enum AdminHttpError {
441 #[error("admin identity is required")]
443 Unauthenticated,
444 #[error("admin privileges are required")]
446 Unauthorized,
447 #[error("diagnostic reset is disabled")]
449 DiagnosticResetDisabled,
450 #[error("diagnostic reset requires a quiescent client surface")]
452 DiagnosticResetBusy,
453 #[error("diagnostic reset failed: {0}")]
455 DiagnosticResetFailed(String),
456 #[error("{0}")]
458 Action(#[from] ServerAdminActionError),
459}
460
461impl IntoResponse for AdminHttpError {
462 fn into_response(self) -> Response {
463 let status = match self {
464 Self::Unauthenticated => StatusCode::UNAUTHORIZED,
465 Self::Unauthorized => StatusCode::FORBIDDEN,
466 Self::DiagnosticResetDisabled => StatusCode::NOT_FOUND,
467 Self::DiagnosticResetBusy => StatusCode::CONFLICT,
468 Self::DiagnosticResetFailed(_) => StatusCode::INTERNAL_SERVER_ERROR,
469 Self::Action(ServerAdminActionError::NotReady(_)) => StatusCode::SERVICE_UNAVAILABLE,
470 Self::Action(
471 ServerAdminActionError::RequiresMember(_) | ServerAdminActionError::BackupDisabled,
472 ) => StatusCode::CONFLICT,
473 Self::Action(ServerAdminActionError::RaftCompaction(
474 RaftCompactionError::Disabled | RaftCompactionError::Unavailable,
475 )) => StatusCode::CONFLICT,
476 Self::Action(ServerAdminActionError::RaftCompaction(RaftCompactionError::Runtime(
477 _,
478 ))) => StatusCode::INTERNAL_SERVER_ERROR,
479 };
480 (status, Json(AdminErrorReply::rejected(self.to_string()))).into_response()
481 }
482}