1use std::sync::Arc;
9
10use axum::Json;
11use axum::extract::State;
12use axum::http::StatusCode;
13use axum::response::{IntoResponse, Response};
14use serde::Serialize;
15
16use super::AdminAuth;
17use crate::state::AppState;
18
19#[derive(Serialize)]
21pub struct SystemHealth {
22 pub status: String,
24
25 pub uptime_secs: u64,
27
28 pub database: SubsystemHealth,
30
31 pub hsm: Option<SubsystemHealth>,
33
34 pub ca_count: usize,
36
37 pub healthy_ca_count: usize,
39
40 pub version: String,
42}
43
44#[derive(Serialize)]
46pub struct SubsystemHealth {
47 pub name: String,
49
50 pub status: String,
52
53 pub detail: Option<String>,
55
56 pub latency_ms: Option<u64>,
58}
59
60pub async fn get_health(_admin: AdminAuth, State(state): State<Arc<AppState>>) -> Response {
68 let uptime = state.startup_time.elapsed().as_secs();
69
70 let db_health = check_database_health(&state).await;
72
73 let hsm_health = if state.config.hsm.is_some() {
75 Some(check_hsm_health(&state).await)
76 } else {
77 None
78 };
79
80 let ca_count = state.config.cas.len();
82 let healthy_ca_count = match state.ha_manager.as_ref() {
83 Some(ha) => ha
86 .pool()
87 .status_snapshot()
88 .into_values()
89 .filter(|s| s.health.is_available())
90 .count(),
91 None => state
97 .config
98 .cas
99 .iter()
100 .filter(|ca_cfg| match state.cas.get(&ca_cfg.id) {
101 Some(ca_state) => {
102 match crate::ha::health::probe_local_ca(ca_cfg, ca_state, state.hsm.as_ref()) {
103 Ok(()) => true,
104 Err(reason) => {
105 tracing::warn!(ca = %ca_cfg.id, %reason, "CA failed liveness check");
106 false
107 }
108 }
109 }
110 None => {
111 tracing::warn!(ca = %ca_cfg.id, "configured CA has no runtime state");
112 false
113 }
114 })
115 .count(),
116 };
117
118 let overall_status =
120 if db_health.status == "unhealthy" || (healthy_ca_count == 0 && ca_count > 0) {
121 "unhealthy"
122 } else if db_health.status == "degraded" || healthy_ca_count < ca_count {
123 "degraded"
124 } else {
125 "healthy"
126 };
127
128 let health = SystemHealth {
129 status: overall_status.to_string(),
130 uptime_secs: uptime,
131 database: db_health,
132 hsm: hsm_health,
133 ca_count,
134 healthy_ca_count,
135 version: env!("CARGO_PKG_VERSION").to_string(),
136 };
137
138 let status_code = if overall_status == "unhealthy" {
139 StatusCode::SERVICE_UNAVAILABLE
140 } else {
141 StatusCode::OK
142 };
143
144 state
145 .record_audit_event("admin_health_check", &format!("status={overall_status}"))
146 .await;
147
148 (status_code, Json(health)).into_response()
149}
150
151pub async fn get_health_db(_admin: AdminAuth, State(state): State<Arc<AppState>>) -> Response {
155 let health = check_database_health(&state).await;
156
157 let status = if health.status == "healthy" {
158 StatusCode::OK
159 } else {
160 StatusCode::SERVICE_UNAVAILABLE
161 };
162
163 state
164 .record_audit_event(
165 "admin_health_check",
166 &format!("subsystem=database, status={}", health.status),
167 )
168 .await;
169
170 (status, Json(health)).into_response()
171}
172
173pub async fn get_health_hsm(_admin: AdminAuth, State(state): State<Arc<AppState>>) -> Response {
178 if state.config.hsm.is_none() {
179 return (
180 StatusCode::OK,
181 Json(SubsystemHealth {
182 name: "hsm".to_string(),
183 status: "not_configured".to_string(),
184 detail: Some("no HSM is configured".to_string()),
185 latency_ms: None,
186 }),
187 )
188 .into_response();
189 }
190
191 let health = check_hsm_health(&state).await;
192 let status = if health.status == "healthy" {
193 StatusCode::OK
194 } else {
195 StatusCode::SERVICE_UNAVAILABLE
196 };
197
198 state
199 .record_audit_event(
200 "admin_health_check",
201 &format!("subsystem=hsm, status={}", health.status),
202 )
203 .await;
204
205 (status, Json(health)).into_response()
206}
207
208pub async fn get_health_ca(_admin: AdminAuth, State(state): State<Arc<AppState>>) -> Response {
213 let mut ca_health: Vec<serde_json::Value> = Vec::new();
214
215 for ca_config in &state.config.cas {
216 let (health, latency_ms) = match state.ha_manager.as_ref() {
217 Some(ha) => {
220 let ca_id_key = crate::ha::CaId(ca_config.id.clone());
221 ha.pool()
222 .status_snapshot()
223 .get(&ca_id_key)
224 .map(|s| (format!("{:?}", s.health), Some(s.latency_ema_ms as u64)))
225 .unwrap_or(("unknown".to_string(), None))
226 }
227 None => match state.cas.get(&ca_config.id) {
233 Some(ca_state) => {
234 match crate::ha::health::probe_local_ca(ca_config, ca_state, state.hsm.as_ref())
235 {
236 Ok(()) => ("Healthy".to_string(), None),
237 Err(reason) => {
238 tracing::warn!(ca = %ca_config.id, %reason, "CA failed liveness check");
239 ("Unavailable".to_string(), None)
240 }
241 }
242 }
243 None => {
244 tracing::warn!(ca = %ca_config.id, "configured CA has no runtime state");
245 ("unknown".to_string(), None)
246 }
247 },
248 };
249
250 ca_health.push(serde_json::json!({
251 "ca_id": ca_config.id,
252 "health": health,
253 "latency_ms": latency_ms,
254 "hsm_backed": ca_config.is_hsm_backed(),
255 }));
256 }
257
258 state
259 .record_audit_event(
260 "admin_health_check",
261 &format!("subsystem=ca, count={}", ca_health.len()),
262 )
263 .await;
264
265 (StatusCode::OK, Json(ca_health)).into_response()
266}
267
268async fn check_database_health(state: &AppState) -> SubsystemHealth {
275 let start = std::time::Instant::now();
276
277 let result = tokio::time::timeout(
278 std::time::Duration::from_secs(2),
279 sqlx::query("SELECT 1").execute(&state.db),
280 )
281 .await;
282
283 match result {
284 Ok(Ok(_)) => {
285 let latency = start.elapsed().as_millis() as u64;
286 SubsystemHealth {
287 name: "database".to_string(),
288 status: "healthy".to_string(),
289 detail: None,
290 latency_ms: Some(latency),
291 }
292 }
293 Ok(Err(e)) => {
294 let latency = start.elapsed().as_millis() as u64;
295 tracing::error!(error = %e, "database health check failed");
296 SubsystemHealth {
297 name: "database".to_string(),
298 status: "unhealthy".to_string(),
299 detail: Some(format!("database unreachable: {e}")),
300 latency_ms: Some(latency),
301 }
302 }
303 Err(_elapsed) => {
304 let latency = start.elapsed().as_millis() as u64;
305 tracing::error!("database health check timed out after 2s");
306 SubsystemHealth {
307 name: "database".to_string(),
308 status: "unhealthy".to_string(),
309 detail: Some("database health check timed out (2s)".to_string()),
310 latency_ms: Some(latency),
311 }
312 }
313 }
314}
315
316async fn check_hsm_health(state: &AppState) -> SubsystemHealth {
318 let start = std::time::Instant::now();
319
320 if let Some(ref hsm) = state.hsm {
321 match hsm.health_check() {
322 Ok(()) => {
323 let latency = start.elapsed().as_millis() as u64;
324 SubsystemHealth {
325 name: "hsm".to_string(),
326 status: "healthy".to_string(),
327 detail: Some("HSM session active".to_string()),
328 latency_ms: Some(latency),
329 }
330 }
331 Err(e) => {
332 let latency = start.elapsed().as_millis() as u64;
333 tracing::error!(error = %e, "HSM health check failed");
334 SubsystemHealth {
335 name: "hsm".to_string(),
336 status: "unhealthy".to_string(),
337 detail: Some(format!("HSM unreachable: {e}")),
338 latency_ms: Some(latency),
339 }
340 }
341 }
342 } else {
343 SubsystemHealth {
344 name: "hsm".to_string(),
345 status: "not_configured".to_string(),
346 detail: Some("no HSM context available".to_string()),
347 latency_ms: None,
348 }
349 }
350}