1use std::io::{Read, Write};
2use std::net::{SocketAddr, TcpListener, TcpStream};
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::thread::{self, JoinHandle};
6use std::time::Duration;
7
8use crate::ServerError;
9
10use super::checks::{SharedReadinessState, health_check, readiness_check};
11
12use super::metrics_route;
13
14const HEALTH_PATH: &str = "/health";
15const READY_PATH: &str = "/ready";
16const METRICS_PATH: &str = "/metrics";
17const APPLICATION_JSON: &str = "application/json";
18const READ_BUFFER_BYTES: usize = 2048;
19
20#[derive(Debug)]
22pub struct HealthServerHandle {
23 local_addr: SocketAddr,
24 shutdown: Arc<AtomicBool>,
25 worker: Option<JoinHandle<Result<(), ServerError>>>,
26}
27
28impl HealthServerHandle {
29 #[must_use]
31 pub const fn local_addr(&self) -> SocketAddr {
32 self.local_addr
33 }
34
35 pub fn shutdown(mut self) -> Result<(), ServerError> {
42 self.stop_worker()
43 }
44
45 fn stop_worker(&mut self) -> Result<(), ServerError> {
46 self.shutdown.store(true, Ordering::SeqCst);
47 let Some(worker) = self.worker.take() else {
48 return Ok(());
49 };
50
51 worker.join().map_err(|_| ServerError::HealthEndpoint {
52 message: "health endpoint worker thread terminated unexpectedly".to_owned(),
53 })?
54 }
55}
56
57impl Drop for HealthServerHandle {
58 fn drop(&mut self) {
59 if let Err(error) = self.stop_worker() {
60 tracing::debug!(%error, "health endpoint shutdown during drop failed");
61 }
62 }
63}
64
65pub fn start_health_server(
76 bind_address: SocketAddr,
77 readiness: SharedReadinessState,
78) -> Result<HealthServerHandle, ServerError> {
79 let listener =
80 TcpListener::bind(bind_address).map_err(|error| ServerError::HealthEndpoint {
81 message: format!("failed to bind health endpoint at {bind_address}: {error}"),
82 })?;
83 listener
84 .set_nonblocking(true)
85 .map_err(|error| ServerError::HealthEndpoint {
86 message: format!("failed to configure health endpoint listener: {error}"),
87 })?;
88 let local_addr = listener
89 .local_addr()
90 .map_err(|error| ServerError::HealthEndpoint {
91 message: format!("failed to inspect health endpoint listener address: {error}"),
92 })?;
93 let shutdown = Arc::new(AtomicBool::new(false));
94 let worker_shutdown = Arc::clone(&shutdown);
95 let worker = thread::spawn(move || serve(&listener, &readiness, &worker_shutdown));
96
97 Ok(HealthServerHandle {
98 local_addr,
99 shutdown,
100 worker: Some(worker),
101 })
102}
103
104fn serve(
105 listener: &TcpListener,
106 readiness: &SharedReadinessState,
107 shutdown: &AtomicBool,
108) -> Result<(), ServerError> {
109 while !shutdown.load(Ordering::SeqCst) {
110 match listener.accept() {
111 Ok((stream, ..)) => {
112 if let Err(error) = handle_connection(stream, readiness) {
118 tracing::debug!(%error, "health endpoint connection error");
119 }
120 }
121 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
122 thread::sleep(Duration::from_millis(10));
123 }
124 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
125 Err(error) => {
126 return Err(ServerError::HealthEndpoint {
127 message: format!("health endpoint accept failed: {error}"),
128 });
129 }
130 }
131 }
132
133 Ok(())
134}
135
136fn handle_connection(
137 mut stream: TcpStream,
138 readiness: &SharedReadinessState,
139) -> Result<(), ServerError> {
140 stream
141 .set_nonblocking(false)
142 .map_err(|error| ServerError::HealthEndpoint {
143 message: format!("failed to configure health request stream: {error}"),
144 })?;
145 stream
146 .set_read_timeout(Some(Duration::from_secs(2)))
147 .map_err(|error| ServerError::HealthEndpoint {
148 message: format!("failed to set health request read timeout: {error}"),
149 })?;
150
151 let mut buffer = [0_u8; READ_BUFFER_BYTES];
152 let bytes_read = stream
153 .read(&mut buffer)
154 .map_err(|error| ServerError::HealthEndpoint {
155 message: format!("failed to read health request: {error}"),
156 })?;
157
158 if bytes_read == 0 {
159 return Ok(());
160 }
161
162 let response = response_for_request(&buffer[..bytes_read], readiness)?;
163 stream
164 .write_all(&response)
165 .map_err(|error| ServerError::HealthEndpoint {
166 message: format!("failed to write health response: {error}"),
167 })?;
168 stream.flush().map_err(|error| ServerError::HealthEndpoint {
169 message: format!("failed to flush health response: {error}"),
170 })
171}
172
173fn response_for_request(
174 request: &[u8],
175 readiness: &SharedReadinessState,
176) -> Result<Vec<u8>, ServerError> {
177 let Ok(request) = std::str::from_utf8(request) else {
178 return Ok(empty_response(StatusCode::BadRequest));
179 };
180 let Some((method, path)) = parse_request_line(request) else {
181 return Ok(empty_response(StatusCode::BadRequest));
182 };
183
184 match (method, path) {
185 ("GET", HEALTH_PATH) => json_response(StatusCode::Ok, &health_check()),
186 ("GET", READY_PATH) => {
187 let status = readiness_check(&readiness.snapshot());
188 let status_code = if status.ready {
189 StatusCode::Ok
190 } else {
191 StatusCode::ServiceUnavailable
192 };
193 json_response(status_code, &status)
194 }
195 ("GET", METRICS_PATH) => Ok(response(
196 StatusCode::Ok,
197 Some(metrics_route::CONTENT_TYPE),
198 metrics_route::render_body().as_bytes(),
199 )),
200 (_, HEALTH_PATH | READY_PATH | METRICS_PATH) => {
201 Ok(empty_response(StatusCode::MethodNotAllowed))
202 }
203 _ => Ok(empty_response(StatusCode::NotFound)),
204 }
205}
206
207fn parse_request_line(request: &str) -> Option<(&str, &str)> {
208 let request_line = request.lines().next()?;
209 let mut parts = request_line.split_whitespace();
210 let method = parts.next()?;
211 let path = parts.next()?;
212 parts.next()?;
213
214 Some((method, path))
215}
216
217fn json_response<T>(status: StatusCode, value: &T) -> Result<Vec<u8>, ServerError>
218where
219 T: serde::Serialize,
220{
221 let body = serde_json::to_vec(value).map_err(|error| ServerError::HealthEndpoint {
222 message: format!("failed to serialize health response: {error}"),
223 })?;
224 Ok(response(status, Some(APPLICATION_JSON), &body))
225}
226
227fn empty_response(status: StatusCode) -> Vec<u8> {
228 response(status, None, &[])
229}
230
231fn response(status: StatusCode, content_type: Option<&str>, body: &[u8]) -> Vec<u8> {
232 let mut response = Vec::new();
233 let status_line = format!("HTTP/1.1 {} {}\r\n", status.code(), status.reason());
234 response.extend_from_slice(status_line.as_bytes());
235 response.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes());
236 response.extend_from_slice(b"Connection: close\r\n");
237 if let Some(content_type) = content_type {
238 response.extend_from_slice(format!("Content-Type: {content_type}\r\n").as_bytes());
239 }
240 response.extend_from_slice(b"\r\n");
241 response.extend_from_slice(body);
242 response
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246enum StatusCode {
247 Ok,
248 BadRequest,
249 NotFound,
250 MethodNotAllowed,
251 ServiceUnavailable,
252}
253
254impl StatusCode {
255 const fn code(self) -> u16 {
256 match self {
257 Self::Ok => 200,
258 Self::BadRequest => 400,
259 Self::NotFound => 404,
260 Self::MethodNotAllowed => 405,
261 Self::ServiceUnavailable => 503,
262 }
263 }
264
265 const fn reason(self) -> &'static str {
266 match self {
267 Self::Ok => "OK",
268 Self::BadRequest => "Bad Request",
269 Self::NotFound => "Not Found",
270 Self::MethodNotAllowed => "Method Not Allowed",
271 Self::ServiceUnavailable => "Service Unavailable",
272 }
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use std::io::{Read, Write};
279 use std::net::{SocketAddr, TcpStream};
280 use std::time::Duration;
281
282 use serde_json::Value;
283
284 use super::{response_for_request, start_health_server};
285 use crate::health::checks::{
286 ClusterReadiness, ReadinessCondition, ReadinessState, SharedReadinessState,
287 };
288
289 fn loopback_ephemeral() -> Result<SocketAddr, Box<dyn std::error::Error>> {
290 Ok("127.0.0.1:0".parse()?)
291 }
292
293 fn get(address: SocketAddr, path: &str) -> Result<String, Box<dyn std::error::Error>> {
294 let mut stream = TcpStream::connect(address)?;
295 stream.set_read_timeout(Some(Duration::from_secs(2)))?;
296 let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n");
297 stream.write_all(request.as_bytes())?;
298
299 let mut response = String::new();
300 stream.read_to_string(&mut response)?;
301 Ok(response)
302 }
303
304 fn assert_status(response: &str, status: u16) {
305 let expected = format!("HTTP/1.1 {status} ");
306 assert!(
307 response.starts_with(&expected),
308 "response status did not start with {expected}: {response}"
309 );
310 }
311
312 fn body(response: &str) -> Result<&str, Box<dyn std::error::Error>> {
313 let Some((_headers, body)) = response.split_once("\r\n\r\n") else {
314 return Err("response did not contain a header/body separator".into());
315 };
316 Ok(body)
317 }
318
319 fn json_body(response: &str) -> Result<Value, Box<dyn std::error::Error>> {
320 Ok(serde_json::from_str(body(response)?)?)
321 }
322
323 #[test]
324 fn health_endpoint_returns_json_200_regardless_of_readiness()
325 -> Result<(), Box<dyn std::error::Error>> {
326 let readiness = SharedReadinessState::new(ReadinessState::default());
327 let server = start_health_server(loopback_ephemeral()?, readiness)?;
328
329 let response = get(server.local_addr(), "/health")?;
330 server.shutdown()?;
331
332 assert_status(&response, 200);
333 assert!(response.contains("Content-Type: application/json\r\n"));
334 let body = json_body(&response)?;
335 assert_eq!(body["status"], "healthy");
336
337 Ok(())
338 }
339
340 #[test]
341 fn ready_endpoint_returns_503_before_main_listener_binds()
342 -> Result<(), Box<dyn std::error::Error>> {
343 let readiness = SharedReadinessState::new(ReadinessState::new(
344 true,
345 false,
346 ClusterReadiness::NotConfigured,
347 ));
348 let server = start_health_server(loopback_ephemeral()?, readiness)?;
349
350 let response = get(server.local_addr(), "/ready")?;
351 server.shutdown()?;
352
353 assert_status(&response, 503);
354 assert!(response.contains("Content-Type: application/json\r\n"));
355 let body = json_body(&response)?;
356 assert_eq!(body["ready"], false);
357 assert_eq!(body["unmet_conditions"][0], "listener_bound");
358
359 Ok(())
360 }
361
362 #[test]
363 fn ready_endpoint_returns_200_after_all_startup_gates() -> Result<(), Box<dyn std::error::Error>>
364 {
365 let readiness = SharedReadinessState::new(ReadinessState::ready_without_cluster());
366 let server = start_health_server(loopback_ephemeral()?, readiness)?;
367
368 let response = get(server.local_addr(), "/ready")?;
369 server.shutdown()?;
370
371 assert_status(&response, 200);
372 let body = json_body(&response)?;
373 assert_eq!(body["ready"], true);
374 let Some(unmet_conditions) = body["unmet_conditions"].as_array() else {
375 return Err("unmet_conditions should be an array".into());
376 };
377 assert!(unmet_conditions.is_empty());
378
379 Ok(())
380 }
381
382 #[test]
383 fn ready_endpoint_updates_from_shared_readiness_state() -> Result<(), Box<dyn std::error::Error>>
384 {
385 let readiness = SharedReadinessState::new(ReadinessState::default());
386 let server = start_health_server(loopback_ephemeral()?, readiness.clone())?;
387
388 let response = get(server.local_addr(), "/ready")?;
389 assert_status(&response, 503);
390
391 readiness.set_config_loaded(true);
392 readiness.set_listener_bound(true);
393 let response = get(server.local_addr(), "/ready")?;
394 server.shutdown()?;
395
396 assert_status(&response, 200);
397
398 Ok(())
399 }
400
401 #[test]
402 fn clustered_ready_transitions_503_to_200_when_membership_established()
403 -> Result<(), Box<dyn std::error::Error>> {
404 let readiness = SharedReadinessState::new(ReadinessState::new(
407 true,
408 true,
409 ClusterReadiness::Configured {
410 membership_established: false,
411 },
412 ));
413 let server = start_health_server(loopback_ephemeral()?, readiness.clone())?;
414
415 let response = get(server.local_addr(), "/ready")?;
416 assert_status(&response, 503);
417 let body = json_body(&response)?;
418 assert_eq!(body["ready"], false);
419 assert_eq!(
420 body["unmet_conditions"][0],
421 serde_json::to_value(ReadinessCondition::ClusterMembershipEstablished)?
422 );
423
424 readiness.set_cluster_membership_established(true);
427 let response = get(server.local_addr(), "/ready")?;
428 server.shutdown()?;
429
430 assert_status(&response, 200);
431 let body = json_body(&response)?;
432 assert_eq!(body["ready"], true);
433 let Some(unmet_conditions) = body["unmet_conditions"].as_array() else {
434 return Err("unmet_conditions should be an array".into());
435 };
436 assert!(unmet_conditions.is_empty());
437
438 Ok(())
439 }
440
441 #[test]
442 fn cluster_readiness_is_listed_when_configured_but_not_joined()
443 -> Result<(), Box<dyn std::error::Error>> {
444 let readiness = SharedReadinessState::new(ReadinessState::new(
445 true,
446 true,
447 ClusterReadiness::Configured {
448 membership_established: false,
449 },
450 ));
451 let response = response_for_request(b"GET /ready HTTP/1.1\r\n\r\n", &readiness)?;
452 let response = String::from_utf8(response)?;
453
454 assert_status(&response, 503);
455 let body = json_body(&response)?;
456 assert_eq!(
457 body["unmet_conditions"][0],
458 serde_json::to_value(ReadinessCondition::ClusterMembershipEstablished)?
459 );
460
461 Ok(())
462 }
463
464 #[test]
465 fn unsupported_paths_are_not_served() -> Result<(), Box<dyn std::error::Error>> {
466 let readiness = SharedReadinessState::default();
467 let response = response_for_request(b"GET /unknown HTTP/1.1\r\n\r\n", &readiness)?;
468 let response = String::from_utf8(response)?;
469
470 assert_status(&response, 404);
471
472 Ok(())
473 }
474
475 #[test]
476 fn unsupported_methods_on_health_paths_are_rejected() -> Result<(), Box<dyn std::error::Error>>
477 {
478 let readiness = SharedReadinessState::default();
479 let response = response_for_request(b"POST /health HTTP/1.1\r\n\r\n", &readiness)?;
480 let response = String::from_utf8(response)?;
481
482 assert_status(&response, 405);
483
484 Ok(())
485 }
486}