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,
Failed(i64),
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 state.status.as_deref() == Some("exited") {
let code = state.exit_code.unwrap_or(0);
if code != 0 {
return HealthVerdict::Failed(code);
}
}
}
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)
}
fn effective_iterations(poll_secs: u64, plan_iters: u64, wait_timeout: Option<Duration>) -> u64 {
match wait_timeout {
Some(wt) => {
let poll = poll_secs.max(1);
let wt_iters = wt.as_secs().div_ceil(poll) + 2;
plan_iters.max(wt_iters)
}
None => plan_iters,
}
}
impl Engine {
pub async fn wait_services_healthy(
&self,
file: &ComposeFile,
target_services: &[String],
) -> Result<()> {
self.wait_services_healthy_within(file, target_services, None)
.await
}
pub async fn wait_services_healthy_within(
&self,
file: &ComposeFile,
target_services: &[String],
wait_timeout: Option<Duration>,
) -> Result<()> {
let waits = file
.services
.iter()
.filter(|(name, _)| {
target_services.is_empty() || target_services.iter().any(|t| t == *name)
})
.map(|(name, service)| {
let container = self.first_replica_name(name, service);
async move { self.wait_healthy(&container, service, wait_timeout).await }
});
futures_util::future::try_join_all(waits).await?;
Ok(())
}
pub(super) async fn wait_healthy(
&self,
container_name: &str,
service: &Service,
wait_timeout: Option<Duration>,
) -> Result<()> {
let hc = service.healthcheck.as_ref();
let (poll_secs, plan_iters) = 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 iterations = effective_iterations(poll_secs, plan_iters, wait_timeout);
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::Failed(code) => {
return Err(ComposeError::WaitServiceExited {
container: container_name.to_string(),
code,
});
}
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_unbounded::<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 effective_iterations_without_wait_timeout_uses_plan() {
assert_eq!(super::effective_iterations(2, 30, None), 30);
}
#[test]
fn effective_iterations_extends_to_cover_wait_timeout() {
let iters = super::effective_iterations(10, 1, Some(Duration::from_secs(120)));
assert_eq!(iters, 14);
}
#[test]
fn effective_iterations_keeps_larger_plan() {
let iters = super::effective_iterations(2, 100, Some(Duration::from_secs(10)));
assert_eq!(iters, 100);
}
#[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));
}
#[test]
fn health_exited_nonzero_fails() {
let info = inspect(r#"{"State":{"Status":"exited","ExitCode":7}}"#);
assert!(matches!(classify_health(&info), HealthVerdict::Failed(7)));
}
#[test]
fn health_exited_zero_is_satisfied() {
let info = inspect(r#"{"State":{"Status":"exited","ExitCode":0}}"#);
assert!(matches!(
classify_health(&info),
HealthVerdict::NoHealthcheck
));
}
}