use serde::{Deserialize, Serialize};
use std::time::Duration;
use thiserror::Error;
use super::DockerClient;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthResponse {
pub healthy: bool,
pub version: String,
}
#[derive(Debug, Serialize)]
pub struct ExtendedHealthResponse {
pub healthy: bool,
pub version: String,
pub container_state: String,
pub uptime_seconds: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_usage_mb: Option<u64>,
}
#[derive(Debug, Error)]
pub enum HealthError {
#[error("Request failed: {0}")]
RequestError(#[from] reqwest::Error),
#[error("Service unhealthy (HTTP {0})")]
Unhealthy(u16),
#[error("Connection refused - service may not be running")]
ConnectionRefused,
#[error("Timeout - service may be starting")]
Timeout,
}
fn format_host(bind_addr: &str) -> String {
if bind_addr.contains(':') && !bind_addr.starts_with('[') {
format!("[{bind_addr}]")
} else {
bind_addr.to_string()
}
}
pub async fn check_health(bind_addr: &str, port: u16) -> Result<HealthResponse, HealthError> {
let host = format_host(bind_addr);
let url = format!("http://{host}:{port}/global/health");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()?;
let response = match client.get(&url).send().await {
Ok(resp) => resp,
Err(e) => {
if e.is_connect() {
return Err(HealthError::ConnectionRefused);
}
if e.is_timeout() {
return Err(HealthError::Timeout);
}
return Err(HealthError::RequestError(e));
}
};
let status = response.status();
if status.is_success() {
let health_response = response.json::<HealthResponse>().await?;
Ok(health_response)
} else {
Err(HealthError::Unhealthy(status.as_u16()))
}
}
pub async fn check_health_extended(
client: &DockerClient,
bind_addr: &str,
port: u16,
) -> Result<ExtendedHealthResponse, HealthError> {
let health = check_health(bind_addr, port).await?;
let container_name = super::active_resource_names().container_name;
let (container_state, uptime_seconds, memory_usage_mb) = match client
.inner()
.inspect_container(&container_name, None)
.await
{
Ok(info) => {
let state = info
.state
.as_ref()
.and_then(|s| s.status.as_ref())
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
let uptime = info
.state
.as_ref()
.and_then(|s| s.started_at.as_ref())
.and_then(|started| {
let timestamp = chrono::DateTime::parse_from_rfc3339(started).ok()?;
let now = chrono::Utc::now();
let started_utc = timestamp.with_timezone(&chrono::Utc);
if now >= started_utc {
Some((now - started_utc).num_seconds() as u64)
} else {
None
}
})
.unwrap_or(0);
let memory = None;
(state, uptime, memory)
}
Err(_) => ("unknown".to_string(), 0, None),
};
Ok(ExtendedHealthResponse {
healthy: health.healthy,
version: health.version,
container_state,
uptime_seconds,
memory_usage_mb,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_health_check_connection_refused() {
let result = check_health("127.0.0.1", 1).await;
assert!(result.is_err());
match result.unwrap_err() {
HealthError::ConnectionRefused => {}
other => panic!("Expected ConnectionRefused, got: {other:?}"),
}
}
#[test]
fn format_host_wraps_ipv6() {
assert_eq!(format_host("::1"), "[::1]");
}
#[test]
fn format_host_preserves_ipv4() {
assert_eq!(format_host("127.0.0.1"), "127.0.0.1");
}
}