Skip to main content

things3_cli/
health.rs

1async fn health_check(State(state): State<AppState>) -> Result<Json<HealthResponse>, StatusCode> {
2    let health_status = state.observability.health_status();
3
4    let response = HealthResponse {
5        status: health_status.status,
6        timestamp: health_status.timestamp.to_string(),
7        uptime: health_status.uptime,
8        version: health_status.version,
9        environment: "production".to_string(),
10        checks: std::collections::HashMap::new(),
11    };
12
13    Ok(Json(response))
14}
15
16async fn readiness_check(
17    State(state): State<AppState>,
18) -> Result<Json<HealthResponse>, StatusCode> {
19    let health_status = state.observability.health_status();
20
21    let response = HealthResponse {
22        status: health_status.status,
23        timestamp: health_status.timestamp.to_string(),
24        uptime: health_status.uptime,
25        version: health_status.version,
26        environment: "production".to_string(),
27        checks: std::collections::HashMap::new(),
28    };
29
30    Ok(Json(response))
31}
32
33async fn liveness_check(State(state): State<AppState>) -> Result<Json<HealthResponse>, StatusCode> {
34    let health_status = state.observability.health_status();
35
36    let response = HealthResponse {
37        status: health_status.status,
38        timestamp: health_status.timestamp.to_string(),
39        uptime: health_status.uptime,
40        version: health_status.version,
41        environment: "production".to_string(),
42        checks: std::collections::HashMap::new(),
43    };
44
45    Ok(Json(response))
46}
47
48async fn metrics_endpoint(State(state): State<AppState>) -> Result<String, StatusCode> {
49    let health_status = state.observability.health_status();
50
51    let metrics = format!(
52        "# HELP health_status Current health status\n\
53         # TYPE health_status gauge\n\
54         health_status{{status=\"{}\"}} {}\n\
55         # HELP uptime_seconds Current uptime in seconds\n\
56         # TYPE uptime_seconds counter\n\
57         uptime_seconds {}\n",
58        health_status.status,
59        i32::from(health_status.status == "healthy"),
60        health_status.uptime.as_secs()
61    );
62
63    Ok(metrics)
64}
65
66use axum::{extract::State, http::StatusCode, response::Json, routing::get, Router};
67use serde::{Deserialize, Serialize};
68use std::sync::Arc;
69use things3_core::{ObservabilityManager, ThingsDatabase};
70use tokio::net::TcpListener;
71use tower_http::cors::CorsLayer;
72use tracing::{info, instrument};
73
74// Struct definitions - must come after all functions to avoid items_after_statements
75/// Application state
76#[derive(Clone)]
77pub struct AppState {
78    pub observability: Arc<ObservabilityManager>,
79    pub database: Arc<ThingsDatabase>,
80}
81
82/// Health response
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct HealthResponse {
85    pub status: String,
86    pub timestamp: String,
87    pub uptime: std::time::Duration,
88    pub version: String,
89    pub environment: String,
90    pub checks: std::collections::HashMap<String, CheckResponse>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct CheckResponse {
95    pub status: String,
96    pub message: Option<String>,
97    pub duration_ms: u64,
98}
99
100impl HealthServer {
101    /// Create a new health check server
102    #[must_use]
103    pub fn new(
104        port: u16,
105        observability: Arc<ObservabilityManager>,
106        database: Arc<ThingsDatabase>,
107    ) -> Self {
108        Self {
109            port,
110            observability,
111            database,
112        }
113    }
114
115    /// Start the health check server
116    ///
117    /// # Errors
118    /// Returns an error if the server fails to start or bind to the port
119    #[instrument(skip(self))]
120    pub async fn start(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
121        let state = AppState {
122            observability: self.observability,
123            database: self.database,
124        };
125
126        let app = Router::new()
127            .route("/health", get(health_check))
128            .route("/ready", get(readiness_check))
129            .route("/live", get(liveness_check))
130            .route("/metrics", get(metrics_endpoint))
131            .layer(CorsLayer::permissive())
132            .with_state(state);
133
134        let listener = TcpListener::bind(format!("0.0.0.0:{}", self.port)).await?;
135        info!("Health check server running on port {}", self.port);
136
137        axum::serve(listener, app).await?;
138        Ok(())
139    }
140}
141
142/// Health check server
143pub struct HealthServer {
144    port: u16,
145    observability: Arc<ObservabilityManager>,
146    database: Arc<ThingsDatabase>,
147}
148
149/// Start the health check server
150///
151/// # Errors
152/// Returns an error if the server fails to start or bind to the port
153#[instrument(skip(observability, database))]
154pub async fn start_health_server(
155    port: u16,
156    observability: Arc<ObservabilityManager>,
157    database: Arc<ThingsDatabase>,
158) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
159    let server = HealthServer::new(port, observability, database);
160    server.start().await
161}
162
163#[cfg(test)]
164#[allow(deprecated)]
165mod tests {
166    use super::*;
167    use tempfile::NamedTempFile;
168
169    #[test]
170    fn test_health_server_creation() {
171        let temp_file = NamedTempFile::new().unwrap();
172        let db_path = temp_file.path();
173
174        let config = things3_core::ThingsConfig::new(db_path, false);
175        let rt = tokio::runtime::Runtime::new().unwrap();
176        let database = Arc::new(
177            rt.block_on(async { ThingsDatabase::new(&config.database_path).await.unwrap() }),
178        );
179
180        let observability = Arc::new(
181            things3_core::ObservabilityManager::new(things3_core::ObservabilityConfig::default())
182                .unwrap(),
183        );
184        let server = HealthServer::new(8080, observability, database);
185        assert_eq!(server.port, 8080);
186    }
187
188    #[test]
189    fn test_health_response() {
190        let response = HealthResponse {
191            status: "healthy".to_string(),
192            timestamp: "2024-01-01T00:00:00Z".to_string(),
193            uptime: std::time::Duration::from_secs(3600),
194            version: "1.0.0".to_string(),
195            environment: "test".to_string(),
196            checks: std::collections::HashMap::new(),
197        };
198
199        assert_eq!(response.status, "healthy");
200        assert_eq!(response.version, "1.0.0");
201    }
202
203    #[test]
204    fn test_health_response_with_checks() {
205        let mut checks = std::collections::HashMap::new();
206        checks.insert(
207            "database".to_string(),
208            CheckResponse {
209                status: "healthy".to_string(),
210                message: Some("Connection successful".to_string()),
211                duration_ms: 5,
212            },
213        );
214        checks.insert(
215            "cache".to_string(),
216            CheckResponse {
217                status: "unhealthy".to_string(),
218                message: Some("Connection failed".to_string()),
219                duration_ms: 100,
220            },
221        );
222
223        let response = HealthResponse {
224            status: "degraded".to_string(),
225            timestamp: "2024-01-01T00:00:00Z".to_string(),
226            uptime: std::time::Duration::from_secs(7200),
227            version: "2.0.0".to_string(),
228            environment: "staging".to_string(),
229            checks,
230        };
231
232        assert_eq!(response.status, "degraded");
233        assert_eq!(response.version, "2.0.0");
234        assert_eq!(response.environment, "staging");
235        assert_eq!(response.checks.len(), 2);
236        assert_eq!(response.uptime.as_secs(), 7200);
237    }
238
239    #[test]
240    fn test_check_response() {
241        let check = CheckResponse {
242            status: "healthy".to_string(),
243            message: Some("All systems operational".to_string()),
244            duration_ms: 10,
245        };
246
247        assert_eq!(check.status, "healthy");
248        assert_eq!(check.message, Some("All systems operational".to_string()));
249        assert_eq!(check.duration_ms, 10);
250    }
251
252    #[test]
253    fn test_check_response_without_message() {
254        let check = CheckResponse {
255            status: "unhealthy".to_string(),
256            message: None,
257            duration_ms: 500,
258        };
259
260        assert_eq!(check.status, "unhealthy");
261        assert_eq!(check.message, None);
262        assert_eq!(check.duration_ms, 500);
263    }
264
265    #[test]
266    fn test_app_state_creation() {
267        let temp_file = NamedTempFile::new().unwrap();
268        let db_path = temp_file.path();
269
270        let config = things3_core::ThingsConfig::new(db_path, false);
271        let rt = tokio::runtime::Runtime::new().unwrap();
272        let database = Arc::new(
273            rt.block_on(async { ThingsDatabase::new(&config.database_path).await.unwrap() }),
274        );
275
276        let observability = Arc::new(
277            things3_core::ObservabilityManager::new(things3_core::ObservabilityConfig::default())
278                .unwrap(),
279        );
280
281        let state = AppState {
282            observability: Arc::clone(&observability),
283            database: Arc::clone(&database),
284        };
285
286        // Test that state can be created and cloned
287        let _cloned_state = state.clone();
288    }
289
290    #[test]
291    fn test_health_server_with_different_ports() {
292        let temp_file = NamedTempFile::new().unwrap();
293        let db_path = temp_file.path();
294
295        let config = things3_core::ThingsConfig::new(db_path, false);
296        let rt = tokio::runtime::Runtime::new().unwrap();
297        let database = Arc::new(
298            rt.block_on(async { ThingsDatabase::new(&config.database_path).await.unwrap() }),
299        );
300
301        let observability = Arc::new(
302            things3_core::ObservabilityManager::new(things3_core::ObservabilityConfig::default())
303                .unwrap(),
304        );
305
306        // Test with different ports
307        let server1 = HealthServer::new(8080, Arc::clone(&observability), Arc::clone(&database));
308        let server2 = HealthServer::new(9090, Arc::clone(&observability), Arc::clone(&database));
309        let server3 = HealthServer::new(3000, Arc::clone(&observability), Arc::clone(&database));
310
311        assert_eq!(server1.port, 8080);
312        assert_eq!(server2.port, 9090);
313        assert_eq!(server3.port, 3000);
314    }
315
316    #[test]
317    fn test_health_response_serialization() {
318        let response = HealthResponse {
319            status: "healthy".to_string(),
320            timestamp: "2024-01-01T00:00:00Z".to_string(),
321            uptime: std::time::Duration::from_secs(3600),
322            version: "1.0.0".to_string(),
323            environment: "test".to_string(),
324            checks: std::collections::HashMap::new(),
325        };
326
327        // Test serialization
328        let json = serde_json::to_string(&response).unwrap();
329        assert!(json.contains("healthy"));
330        assert!(json.contains("1.0.0"));
331
332        // Test deserialization
333        let deserialized: HealthResponse = serde_json::from_str(&json).unwrap();
334        assert_eq!(deserialized.status, response.status);
335        assert_eq!(deserialized.version, response.version);
336    }
337
338    #[test]
339    fn test_check_response_serialization() {
340        let check = CheckResponse {
341            status: "healthy".to_string(),
342            message: Some("All systems operational".to_string()),
343            duration_ms: 10,
344        };
345
346        // Test serialization
347        let json = serde_json::to_string(&check).unwrap();
348        assert!(json.contains("healthy"));
349        assert!(json.contains("All systems operational"));
350
351        // Test deserialization
352        let deserialized: CheckResponse = serde_json::from_str(&json).unwrap();
353        assert_eq!(deserialized.status, check.status);
354        assert_eq!(deserialized.message, check.message);
355        assert_eq!(deserialized.duration_ms, check.duration_ms);
356    }
357
358    #[test]
359    fn test_health_response_debug_formatting() {
360        let response = HealthResponse {
361            status: "healthy".to_string(),
362            timestamp: "2024-01-01T00:00:00Z".to_string(),
363            uptime: std::time::Duration::from_secs(3600),
364            version: "1.0.0".to_string(),
365            environment: "test".to_string(),
366            checks: std::collections::HashMap::new(),
367        };
368
369        let debug_str = format!("{response:?}");
370        assert!(debug_str.contains("healthy"));
371        assert!(debug_str.contains("1.0.0"));
372    }
373
374    #[test]
375    fn test_check_response_debug_formatting() {
376        let check = CheckResponse {
377            status: "unhealthy".to_string(),
378            message: Some("Connection failed".to_string()),
379            duration_ms: 100,
380        };
381
382        let debug_str = format!("{check:?}");
383        assert!(debug_str.contains("unhealthy"));
384        assert!(debug_str.contains("Connection failed"));
385    }
386
387    #[test]
388    fn test_health_response_clone() {
389        let mut checks = std::collections::HashMap::new();
390        checks.insert(
391            "database".to_string(),
392            CheckResponse {
393                status: "healthy".to_string(),
394                message: Some("OK".to_string()),
395                duration_ms: 5,
396            },
397        );
398
399        let response = HealthResponse {
400            status: "healthy".to_string(),
401            timestamp: "2024-01-01T00:00:00Z".to_string(),
402            uptime: std::time::Duration::from_secs(3600),
403            version: "1.0.0".to_string(),
404            environment: "test".to_string(),
405            checks,
406        };
407
408        let cloned = response.clone();
409        assert_eq!(cloned.status, response.status);
410        assert_eq!(cloned.version, response.version);
411        assert_eq!(cloned.checks.len(), response.checks.len());
412    }
413
414    #[test]
415    fn test_check_response_clone() {
416        let check = CheckResponse {
417            status: "healthy".to_string(),
418            message: Some("OK".to_string()),
419            duration_ms: 5,
420        };
421
422        let cloned = check.clone();
423        assert_eq!(cloned.status, check.status);
424        assert_eq!(cloned.message, check.message);
425        assert_eq!(cloned.duration_ms, check.duration_ms);
426    }
427
428    #[test]
429    fn test_health_response_with_empty_checks() {
430        let response = HealthResponse {
431            status: "healthy".to_string(),
432            timestamp: "2024-01-01T00:00:00Z".to_string(),
433            uptime: std::time::Duration::from_secs(0),
434            version: "0.1.0".to_string(),
435            environment: "development".to_string(),
436            checks: std::collections::HashMap::new(),
437        };
438
439        assert_eq!(response.status, "healthy");
440        assert_eq!(response.uptime.as_secs(), 0);
441        assert_eq!(response.checks.len(), 0);
442    }
443
444    #[test]
445    fn test_health_response_with_multiple_checks() {
446        let mut checks = std::collections::HashMap::new();
447        checks.insert(
448            "database".to_string(),
449            CheckResponse {
450                status: "healthy".to_string(),
451                message: Some("Connection OK".to_string()),
452                duration_ms: 2,
453            },
454        );
455        checks.insert(
456            "redis".to_string(),
457            CheckResponse {
458                status: "healthy".to_string(),
459                message: Some("Cache OK".to_string()),
460                duration_ms: 1,
461            },
462        );
463        checks.insert(
464            "api".to_string(),
465            CheckResponse {
466                status: "unhealthy".to_string(),
467                message: Some("Service down".to_string()),
468                duration_ms: 1000,
469            },
470        );
471
472        let response = HealthResponse {
473            status: "degraded".to_string(),
474            timestamp: "2024-01-01T00:00:00Z".to_string(),
475            uptime: std::time::Duration::from_secs(86400), // 24 hours
476            version: "3.0.0".to_string(),
477            environment: "production".to_string(),
478            checks,
479        };
480
481        assert_eq!(response.status, "degraded");
482        assert_eq!(response.checks.len(), 3);
483        assert_eq!(response.uptime.as_secs(), 86400);
484        assert_eq!(response.environment, "production");
485    }
486}