Skip to main content

camel_api/
health.rs

1//! Health monitoring types for rust-camel.
2//!
3//! This module provides types for tracking and reporting the health status
4//! of services in a rust-camel application.
5
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::lifecycle::{HealthStatus, ServiceStatus};
11
12/// System-wide health report containing aggregated status of all services.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct HealthReport {
15    pub status: HealthStatus,
16    pub services: Vec<ServiceHealth>,
17    pub timestamp: DateTime<Utc>,
18}
19
20impl Default for HealthReport {
21    fn default() -> Self {
22        Self {
23            status: HealthStatus::Healthy,
24            services: Vec::new(),
25            timestamp: Utc::now(),
26        }
27    }
28}
29
30/// Health status of an individual service.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ServiceHealth {
33    pub name: String,
34    pub status: ServiceStatus,
35    #[serde(default)]
36    pub message: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CheckResult {
41    pub name: String,
42    pub status: HealthStatus,
43    pub message: Option<String>,
44}
45
46impl CheckResult {
47    pub fn healthy(name: &str) -> Self {
48        Self {
49            name: name.to_string(),
50            status: HealthStatus::Healthy,
51            message: None,
52        }
53    }
54
55    pub fn unhealthy(name: &str, reason: &str) -> Self {
56        Self {
57            name: name.to_string(),
58            status: HealthStatus::Unhealthy,
59            message: Some(reason.to_string()),
60        }
61    }
62
63    pub fn degraded(name: &str, reason: &str) -> Self {
64        Self {
65            name: name.to_string(),
66            status: HealthStatus::Degraded,
67            message: Some(reason.to_string()),
68        }
69    }
70}
71
72#[async_trait]
73pub trait AsyncHealthCheck: Send + Sync {
74    fn name(&self) -> &str;
75    async fn check(&self) -> CheckResult;
76}
77
78/// Programmatic health state readable by platform adapters.
79/// `camel-health` implements this; `camel-platform-kubernetes` consumes it via this trait.
80/// Neither crate depends on the other.
81#[async_trait]
82pub trait HealthSource: Send + Sync {
83    async fn liveness(&self) -> HealthStatus;
84    async fn readiness(&self) -> HealthStatus;
85
86    async fn health_report(&self) -> HealthReport {
87        HealthReport {
88            status: self.readiness().await,
89            services: vec![],
90            timestamp: chrono::Utc::now(),
91        }
92    }
93
94    /// Default: bare-status [`HealthReport`] for liveness, with an empty
95    /// `services` array. Detail-capable sources override this to populate
96    /// [`HealthReport::services`].
97    async fn liveness_report(&self) -> HealthReport {
98        HealthReport {
99            status: self.liveness().await,
100            ..Default::default()
101        }
102    }
103
104    /// Default: bare-status [`HealthReport`] for readiness, with an empty
105    /// `services` array. Detail-capable sources override this to populate
106    /// [`HealthReport::services`].
107    async fn readiness_report(&self) -> HealthReport {
108        HealthReport {
109            status: self.readiness().await,
110            ..Default::default()
111        }
112    }
113
114    /// Default: bare-status [`HealthReport`] for startup, with an empty
115    /// `services` array. Detail-capable sources override this to populate
116    /// [`HealthReport::services`].
117    async fn startup_report(&self) -> HealthReport {
118        HealthReport {
119            status: self.startup().await,
120            ..Default::default()
121        }
122    }
123
124    /// Default: `Healthy` — non-K8s implementors need not override.
125    async fn startup(&self) -> HealthStatus {
126        HealthStatus::Healthy
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[tokio::test]
135    async fn test_health_source_default_startup() {
136        struct MinimalSource;
137        #[async_trait]
138        impl HealthSource for MinimalSource {
139            async fn liveness(&self) -> HealthStatus {
140                HealthStatus::Healthy
141            }
142
143            async fn readiness(&self) -> HealthStatus {
144                HealthStatus::Healthy
145            }
146        }
147        let s = MinimalSource;
148        assert_eq!(s.startup().await, HealthStatus::Healthy);
149    }
150
151    #[tokio::test]
152    async fn test_health_source_custom_startup() {
153        struct BootingSource;
154        #[async_trait]
155        impl HealthSource for BootingSource {
156            async fn liveness(&self) -> HealthStatus {
157                HealthStatus::Healthy
158            }
159
160            async fn readiness(&self) -> HealthStatus {
161                HealthStatus::Healthy
162            }
163
164            async fn startup(&self) -> HealthStatus {
165                HealthStatus::Unhealthy
166            }
167        }
168        let s = BootingSource;
169        assert_eq!(s.startup().await, HealthStatus::Unhealthy);
170    }
171
172    #[test]
173    fn test_health_report_serialization() {
174        let report = HealthReport {
175            status: HealthStatus::Healthy,
176            services: vec![ServiceHealth {
177                name: "prometheus".to_string(),
178                status: ServiceStatus::Started,
179                message: None,
180            }],
181            timestamp: chrono::Utc::now(),
182        };
183
184        let json = serde_json::to_string(&report).unwrap();
185        assert!(json.contains("Healthy"));
186        assert!(json.contains("prometheus"));
187        assert!(json.contains("Started"));
188        assert!(json.contains("timestamp"));
189    }
190
191    #[test]
192    fn test_health_report_default() {
193        let report = HealthReport::default();
194        assert_eq!(report.status, HealthStatus::Healthy);
195        assert!(report.services.is_empty());
196        assert!(report.timestamp <= chrono::Utc::now());
197    }
198
199    #[test]
200    fn test_service_health_serialization_round_trip() {
201        let svc = ServiceHealth {
202            name: "kafka".to_string(),
203            status: ServiceStatus::Stopped,
204            message: None,
205        };
206
207        let json = serde_json::to_string(&svc).unwrap();
208        assert!(json.contains("kafka"));
209        assert!(json.contains("Stopped"));
210
211        let decoded: ServiceHealth = serde_json::from_str(&json).unwrap();
212        assert_eq!(decoded.name, "kafka");
213        assert_eq!(decoded.status, ServiceStatus::Stopped);
214        assert!(decoded.message.is_none());
215    }
216
217    #[tokio::test]
218    async fn test_health_source_liveness_and_readiness() {
219        struct MixedSource;
220        #[async_trait]
221        impl HealthSource for MixedSource {
222            async fn liveness(&self) -> HealthStatus {
223                HealthStatus::Healthy
224            }
225
226            async fn readiness(&self) -> HealthStatus {
227                HealthStatus::Unhealthy
228            }
229        }
230
231        let s = MixedSource;
232        assert_eq!(s.liveness().await, HealthStatus::Healthy);
233        assert_eq!(s.readiness().await, HealthStatus::Unhealthy);
234    }
235
236    #[test]
237    fn test_health_report_serialization_round_trip() {
238        let report = HealthReport {
239            status: HealthStatus::Unhealthy,
240            services: vec![
241                ServiceHealth {
242                    name: "db".to_string(),
243                    status: ServiceStatus::Started,
244                    message: None,
245                },
246                ServiceHealth {
247                    name: "queue".to_string(),
248                    status: ServiceStatus::Stopped,
249                    message: None,
250                },
251            ],
252            timestamp: chrono::Utc::now(),
253        };
254
255        let json = serde_json::to_string(&report).unwrap();
256        let back: HealthReport = serde_json::from_str(&json).unwrap();
257        assert_eq!(back.status, HealthStatus::Unhealthy);
258        assert_eq!(back.services.len(), 2);
259        assert_eq!(back.services[0].name, "db");
260        assert_eq!(back.services[1].status, ServiceStatus::Stopped);
261    }
262
263    #[tokio::test]
264    async fn test_health_source_startup_override_independent_from_readiness() {
265        struct StartupOnly;
266        #[async_trait]
267        impl HealthSource for StartupOnly {
268            async fn liveness(&self) -> HealthStatus {
269                HealthStatus::Healthy
270            }
271
272            async fn readiness(&self) -> HealthStatus {
273                HealthStatus::Unhealthy
274            }
275
276            async fn startup(&self) -> HealthStatus {
277                HealthStatus::Healthy
278            }
279        }
280
281        let source = StartupOnly;
282        assert_eq!(source.liveness().await, HealthStatus::Healthy);
283        assert_eq!(source.readiness().await, HealthStatus::Unhealthy);
284        assert_eq!(source.startup().await, HealthStatus::Healthy);
285    }
286
287    #[test]
288    fn test_check_result_healthy() {
289        let r = CheckResult::healthy("kafka");
290        assert_eq!(r.name, "kafka");
291        assert_eq!(r.status, HealthStatus::Healthy);
292        assert!(r.message.is_none());
293    }
294
295    #[test]
296    fn test_check_result_unhealthy() {
297        let r = CheckResult::unhealthy("redis", "connection refused");
298        assert_eq!(r.name, "redis");
299        assert_eq!(r.status, HealthStatus::Unhealthy);
300        assert_eq!(r.message.as_deref(), Some("connection refused"));
301    }
302
303    #[test]
304    fn test_check_result_degraded() {
305        let r = CheckResult::degraded("sql", "pool exhausted");
306        assert_eq!(r.name, "sql");
307        assert_eq!(r.status, HealthStatus::Degraded);
308        assert_eq!(r.message.as_deref(), Some("pool exhausted"));
309    }
310
311    #[test]
312    fn test_check_result_serialization_round_trip() {
313        let r = CheckResult::unhealthy("opensearch", "timeout");
314        let json = serde_json::to_string(&r).unwrap();
315        let back: CheckResult = serde_json::from_str(&json).unwrap();
316        assert_eq!(back.name, "opensearch");
317        assert_eq!(back.status, HealthStatus::Unhealthy);
318        assert_eq!(back.message.as_deref(), Some("timeout"));
319    }
320
321    #[tokio::test]
322    async fn report_defaults_mirror_bare_status() {
323        struct MinimalSource;
324        #[async_trait]
325        impl HealthSource for MinimalSource {
326            async fn liveness(&self) -> HealthStatus {
327                HealthStatus::Healthy
328            }
329
330            async fn readiness(&self) -> HealthStatus {
331                HealthStatus::Healthy
332            }
333        }
334
335        let s = MinimalSource;
336        for report in [
337            s.liveness_report().await,
338            s.readiness_report().await,
339            s.startup_report().await,
340        ] {
341            assert_eq!(report.status, HealthStatus::Healthy);
342            assert!(report.services.is_empty());
343        }
344    }
345
346    #[tokio::test]
347    async fn report_defaults_carry_unhealthy_status() {
348        struct UnreadySource;
349        #[async_trait]
350        impl HealthSource for UnreadySource {
351            async fn liveness(&self) -> HealthStatus {
352                HealthStatus::Healthy
353            }
354
355            async fn readiness(&self) -> HealthStatus {
356                HealthStatus::Unhealthy
357            }
358        }
359
360        let report = UnreadySource.readiness_report().await;
361        assert_eq!(report.status, HealthStatus::Unhealthy);
362        assert!(report.services.is_empty());
363    }
364}