Skip to main content

tatara_engine/domain/
health_probe.rs

1//! Health probe executor for HTTP, TCP, and Exec checks.
2//!
3//! Executes health probes declared in task specs and returns results
4//! that feed into the reconciler's restart logic and service catalog.
5
6use anyhow::Result;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10use tatara_core::domain::job::HealthCheck;
11use tokio::net::TcpStream;
12use tokio::process::Command;
13use tracing::{debug, warn};
14
15/// Result of a single probe execution.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ProbeResult {
19    Passing { latency_ms: u64 },
20    Warning { message: String, latency_ms: u64 },
21    Critical { message: String },
22    Timeout,
23}
24
25impl ProbeResult {
26    pub fn is_passing(&self) -> bool {
27        matches!(self, Self::Passing { .. })
28    }
29
30    pub fn is_failing(&self) -> bool {
31        matches!(self, Self::Critical { .. } | Self::Timeout)
32    }
33}
34
35/// Tracked state for a health probe across reconciler ticks.
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37pub struct ProbeState {
38    pub last_result: Option<ProbeResult>,
39    pub consecutive_failures: u32,
40    pub consecutive_successes: u32,
41    pub last_checked: Option<DateTime<Utc>>,
42}
43
44/// Executes health probes against running tasks.
45pub struct ProbeExecutor {
46    client: reqwest::Client,
47}
48
49impl ProbeExecutor {
50    pub fn new() -> Self {
51        let client = reqwest::Client::builder()
52            .timeout(Duration::from_secs(10))
53            .no_proxy()
54            .build()
55            .unwrap_or_default();
56        Self { client }
57    }
58
59    /// Execute a health check and return the result.
60    pub async fn execute(&self, check: &HealthCheck) -> ProbeResult {
61        match check {
62            HealthCheck::Http {
63                port,
64                path,
65                timeout_secs,
66                ..
67            } => self.probe_http(*port, path, *timeout_secs).await,
68            HealthCheck::Tcp {
69                port, timeout_secs, ..
70            } => self.probe_tcp(*port, *timeout_secs).await,
71            HealthCheck::Exec {
72                command,
73                timeout_secs,
74                ..
75            } => self.probe_exec(command, *timeout_secs).await,
76        }
77    }
78
79    async fn probe_http(&self, port: u16, path: &str, timeout_secs: u64) -> ProbeResult {
80        let url = format!("http://127.0.0.1:{port}{path}");
81        let start = std::time::Instant::now();
82
83        match tokio::time::timeout(
84            Duration::from_secs(timeout_secs),
85            self.client.get(&url).send(),
86        )
87        .await
88        {
89            Ok(Ok(resp)) => {
90                let latency_ms = start.elapsed().as_millis() as u64;
91                let status = resp.status();
92                if status.is_success() {
93                    debug!(url = %url, status = %status, latency_ms, "health check passed");
94                    ProbeResult::Passing { latency_ms }
95                } else if status.as_u16() == 429 {
96                    ProbeResult::Warning {
97                        message: format!("rate limited (HTTP 429)"),
98                        latency_ms,
99                    }
100                } else if status.is_server_error() {
101                    ProbeResult::Critical {
102                        message: format!("HTTP {status}"),
103                    }
104                } else {
105                    ProbeResult::Warning {
106                        message: format!("HTTP {status}"),
107                        latency_ms,
108                    }
109                }
110            }
111            Ok(Err(e)) => {
112                warn!(url = %url, error = %e, "health check failed");
113                ProbeResult::Critical {
114                    message: e.to_string(),
115                }
116            }
117            Err(_) => ProbeResult::Timeout,
118        }
119    }
120
121    async fn probe_tcp(&self, port: u16, timeout_secs: u64) -> ProbeResult {
122        let addr = format!("127.0.0.1:{port}");
123        let start = std::time::Instant::now();
124
125        match tokio::time::timeout(Duration::from_secs(timeout_secs), TcpStream::connect(&addr))
126            .await
127        {
128            Ok(Ok(_)) => {
129                let latency_ms = start.elapsed().as_millis() as u64;
130                debug!(addr = %addr, latency_ms, "TCP health check passed");
131                ProbeResult::Passing { latency_ms }
132            }
133            Ok(Err(e)) => ProbeResult::Critical {
134                message: e.to_string(),
135            },
136            Err(_) => ProbeResult::Timeout,
137        }
138    }
139
140    async fn probe_exec(&self, command: &str, timeout_secs: u64) -> ProbeResult {
141        let start = std::time::Instant::now();
142
143        let parts: Vec<&str> = command.split_whitespace().collect();
144        let (cmd, args) = match parts.split_first() {
145            Some((cmd, args)) => (*cmd, args),
146            None => {
147                return ProbeResult::Critical {
148                    message: "empty command".to_string(),
149                }
150            }
151        };
152
153        match tokio::time::timeout(
154            Duration::from_secs(timeout_secs),
155            Command::new(cmd).args(args).output(),
156        )
157        .await
158        {
159            Ok(Ok(output)) => {
160                let latency_ms = start.elapsed().as_millis() as u64;
161                if output.status.success() {
162                    ProbeResult::Passing { latency_ms }
163                } else {
164                    ProbeResult::Critical {
165                        message: format!("exit code: {:?}", output.status.code()),
166                    }
167                }
168            }
169            Ok(Err(e)) => ProbeResult::Critical {
170                message: e.to_string(),
171            },
172            Err(_) => ProbeResult::Timeout,
173        }
174    }
175}
176
177impl Default for ProbeExecutor {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[tokio::test]
188    async fn test_tcp_probe_refuses_unbound_port() {
189        let executor = ProbeExecutor::new();
190        let result = executor.probe_tcp(59999, 2).await;
191        assert!(result.is_failing());
192    }
193
194    #[tokio::test]
195    async fn test_exec_probe_true() {
196        let executor = ProbeExecutor::new();
197        let result = executor.probe_exec("true", 5).await;
198        assert!(result.is_passing());
199    }
200
201    #[tokio::test]
202    async fn test_exec_probe_false() {
203        let executor = ProbeExecutor::new();
204        let result = executor.probe_exec("false", 5).await;
205        assert!(result.is_failing());
206    }
207
208    #[test]
209    fn test_probe_result_warning() {
210        let result = ProbeResult::Warning {
211            message: "rate limited".to_string(),
212            latency_ms: 42,
213        };
214        assert!(!result.is_passing());
215        assert!(!result.is_failing()); // Warning is neither passing nor failing
216    }
217
218    #[test]
219    fn test_probe_state_default() {
220        let state = ProbeState::default();
221        assert_eq!(state.consecutive_failures, 0);
222        assert_eq!(state.consecutive_successes, 0);
223        assert!(state.last_result.is_none());
224    }
225}