use std::time::Duration;
use serde::Deserialize;
use crate::compose::types::{ComposeFile, Service};
use crate::error::{ComposeError, Result};
use crate::libpod::types::container::ContainerInspect;
use crate::libpod::API_PREFIX;
use super::Engine;
#[derive(Deserialize)]
struct HealthCheckRun {
#[serde(rename = "Status")]
status: Option<String>,
}
enum HealthVerdict {
Healthy,
NoHealthcheck,
Pending,
}
fn classify_health(info: &ContainerInspect) -> HealthVerdict {
if let Some(state) = &info.state {
if let Some(health) = &state.health {
if health.status.as_deref() == Some("healthy") {
return HealthVerdict::Healthy;
}
}
}
if !info
.config
.as_ref()
.map(|c| c.has_healthcheck())
.unwrap_or(false)
{
return HealthVerdict::NoHealthcheck;
}
HealthVerdict::Pending
}
fn health_poll_plan(
interval: Option<&str>,
start_period: Option<&str>,
retries: Option<u32>,
) -> (u64, u64) {
let poll_secs = interval
.and_then(crate::size::parse_duration_secs)
.filter(|s| *s >= 1)
.unwrap_or(2);
let start_secs = start_period
.and_then(crate::size::parse_duration_secs)
.unwrap_or(0);
let iterations = retries.unwrap_or(30) as u64 + start_secs / poll_secs;
(poll_secs, iterations)
}
impl Engine {
pub async fn wait_services_healthy(
&self,
file: &ComposeFile,
target_services: &[String],
) -> Result<()> {
for (name, service) in &file.services {
if !target_services.is_empty() && !target_services.iter().any(|t| t == name) {
continue;
}
let container = self.first_replica_name(name, service);
self.wait_healthy(&container, service).await?;
}
Ok(())
}
pub(super) async fn wait_healthy(&self, container_name: &str, service: &Service) -> Result<()> {
let hc = service.healthcheck.as_ref();
let (poll_secs, iterations) = health_poll_plan(
hc.and_then(|h| h.interval.as_deref()),
hc.and_then(|h| h.start_period.as_deref()),
hc.and_then(|h| h.retries),
);
let info = self
.client
.get_json::<crate::libpod::types::container::ContainerInspect>(&format!(
"{API_PREFIX}/containers/{}/json",
crate::libpod::urlencoded(container_name),
))
.await
.map_err(ComposeError::Podman)?;
match classify_health(&info) {
HealthVerdict::Healthy => return Ok(()),
HealthVerdict::NoHealthcheck => {
tracing::debug!(
"{container_name} has no effective healthcheck; treating service_healthy as satisfied"
);
return Ok(());
}
HealthVerdict::Pending => {}
}
let path = format!(
"{API_PREFIX}/containers/{}/healthcheck",
crate::libpod::urlencoded(container_name),
);
for _ in 0..iterations {
match self.client.get_json::<HealthCheckRun>(&path).await {
Ok(run) if run.status.as_deref() == Some("healthy") => return Ok(()),
Ok(_) => {}
Err(e) => tracing::debug!("{container_name} healthcheck run failed: {e}"),
}
tokio::time::sleep(Duration::from_secs(poll_secs)).await;
}
Err(ComposeError::HealthCheckTimeout(container_name.into()))
}
pub(super) async fn wait_completed(&self, container_name: &str) -> Result<()> {
let path = format!(
"{API_PREFIX}/containers/{}/wait?condition=stopped",
crate::libpod::urlencoded(container_name),
);
let budget = std::time::Duration::from_secs(600);
match tokio::time::timeout(budget, self.client.post_empty_json::<i64>(&path)).await {
Ok(Ok(0)) => Ok(()),
Ok(Ok(code)) => Err(ComposeError::HealthCheckTimeout(format!(
"{container_name} exited with non-zero status {code}"
))),
Ok(Err(e)) => {
tracing::debug!("{container_name} wait?condition=stopped failed: {e}");
Err(ComposeError::HealthCheckTimeout(container_name.into()))
}
Err(_elapsed) => Err(ComposeError::HealthCheckTimeout(container_name.into())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn inspect(json: &str) -> ContainerInspect {
serde_json::from_str(json).expect("fixture parses")
}
#[test]
fn poll_plan_defaults_match_legacy_60s() {
assert_eq!(super::health_poll_plan(None, None, None), (2, 30));
}
#[test]
fn poll_plan_uses_interval_and_honors_start_period() {
let (poll, iters) = super::health_poll_plan(Some("10s"), Some("60s"), Some(3));
assert_eq!((poll, iters), (10, 9));
}
#[test]
fn poll_plan_sub_second_interval_floors_to_default() {
let (poll, _) = super::health_poll_plan(Some("500ms"), None, Some(5));
assert_eq!(poll, 2);
}
#[test]
fn health_reported_healthy() {
let info = inspect(r#"{"State":{"Status":"running","Health":{"Status":"healthy"}}}"#);
assert!(matches!(classify_health(&info), HealthVerdict::Healthy));
}
#[test]
fn health_no_effective_healthcheck_is_satisfied() {
let info =
inspect(r#"{"State":{"Status":"running"},"Config":{"Healthcheck":{"Test":["NONE"]}}}"#);
assert!(matches!(
classify_health(&info),
HealthVerdict::NoHealthcheck
));
}
#[test]
fn health_starting_with_healthcheck_pends() {
let info = inspect(
r#"{"State":{"Status":"running","Health":{"Status":"starting"}},"Config":{"Healthcheck":{"Test":["CMD","true"]}}}"#,
);
assert!(matches!(classify_health(&info), HealthVerdict::Pending));
}
}