Added health check and deployment setup for one container per customer. Fixes #12

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 16:34:40 +02:00
co-authored by Claude Opus 5.5
parent c1d998878a
commit 156fe1a0f4
14 changed files with 799 additions and 4 deletions
+94
View File
@@ -0,0 +1,94 @@
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<AppData>) -> 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));
}
}