use std::io::{Read, Write}; use std::net::{SocketAddr, TcpStream}; use std::time::Duration; use actix_web::{get, HttpResponse, Responder}; use actix_web::web::Data; use log::error; use crate::backend::AppData; /// Health of the instance for the reverse proxy and for monitoring. The application is /// healthy when it can talk to its database. #[get("/health")] pub async fn health(app_data: Data) -> impl Responder { match sqlx::query("SELECT 1").execute(app_data.db_pool()).await { Ok(_) => HttpResponse::Ok().body("ok"), Err(e) => { error!("Health check failed: {}", e); HttpResponse::ServiceUnavailable().body("database unavailable") } } } /// Asks a running instance on this machine for its health. Used by the container health /// check (`rezervator --healthcheck`), so the image doesn't need curl. pub fn is_healthy(port: u16) -> bool { let timeout = Duration::from_secs(3); let addr = SocketAddr::from(([127, 0, 0, 1], port)); let Ok(mut stream) = TcpStream::connect_timeout(&addr, timeout) else { return false }; let _ = stream.set_read_timeout(Some(timeout)); let _ = stream.set_write_timeout(Some(timeout)); if stream.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n").is_err() { return false } let mut response = [0u8; 64]; let read = stream.read(&mut response).unwrap_or(0); String::from_utf8_lossy(&response[..read]).lines().next() .map(|status| { let mut parts = status.split_whitespace(); parts.next().is_some_and(|version| version.starts_with("HTTP/")) && parts.next() == Some("200") }) .unwrap_or(false) } #[cfg(test)] mod tests { use super::*; use std::net::TcpListener; /// Starts a server that answers one request with the given response, returns its port fn serve_once(response: &'static str) -> u16 { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); std::thread::spawn(move || { if let Ok((mut stream, _)) = listener.accept() { let mut request = [0u8; 512]; let _ = stream.read(&mut request); let _ = stream.write_all(response.as_bytes()); } }); port } #[test] fn healthy_when_server_answers_200() { assert!(is_healthy(serve_once("HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok"))); } #[test] fn unhealthy_when_database_is_down() { assert!(!is_healthy(serve_once("HTTP/1.1 503 Service Unavailable\r\ncontent-length: 0\r\n\r\n"))); } #[test] fn unhealthy_on_other_answers() { assert!(!is_healthy(serve_once("HTTP/1.1 302 Found\r\nlocation: /login\r\n\r\n"))); assert!(!is_healthy(serve_once(""))); assert!(!is_healthy(serve_once("garbage 200"))); } #[test] fn unhealthy_when_nothing_listens() { // Take a free port and release it again let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); assert!(!is_healthy(port)); } }