tatara_engine/domain/
health_probe.rs1use 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#[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#[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
44pub 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 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()); }
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}