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
}
const STATUS_READ_INTERVAL: Duration = Duration::from_millis(150);
const MIN_RUN_INTERVAL: Duration = Duration::from_millis(100);
fn health_poll_plan(
interval: Option<&str>,
start_period: Option<&str>,
retries: Option<u32>,
) -> (Duration, Duration) {
let run_interval = interval
.and_then(crate::size::parse_duration_nanos)
.filter(|n| *n > 0)
.map(|n| Duration::from_nanos(n as u64).max(MIN_RUN_INTERVAL))
.unwrap_or(Duration::from_secs(2));
let start = start_period
.and_then(crate::size::parse_duration_nanos)
.filter(|n| *n > 0)
.map(|n| Duration::from_nanos(n as u64))
.unwrap_or_default();
let budget = run_interval.saturating_mul(retries.unwrap_or(30)) + start;
(run_interval, budget)
}
fn effective_budget(
run_interval: Duration,
plan_budget: Duration,
wait_timeout: Option<Duration>,
) -> Duration {
match wait_timeout {
Some(wt) => plan_budget.max(wt + run_interval.saturating_mul(2)),
None => plan_budget,
}
}
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 (run_interval, plan_budget) = 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 budget = effective_budget(run_interval, plan_budget, 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),
);
let inspect_path = format!(
"{API_PREFIX}/containers/{}/json",
crate::libpod::urlencoded(container_name),
);
let deadline = tokio::time::Instant::now() + budget;
let mut next_run = tokio::time::Instant::now();
while tokio::time::Instant::now() < deadline {
if tokio::time::Instant::now() >= next_run {
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}"),
}
next_run = tokio::time::Instant::now() + run_interval;
} else if let Ok(info) = self
.client
.get_json::<crate::libpod::types::container::ContainerInspect>(&inspect_path)
.await
{
match classify_health(&info) {
HealthVerdict::Healthy => return Ok(()),
HealthVerdict::Failed(code) => {
return Err(ComposeError::WaitServiceExited {
container: container_name.to_string(),
code,
})
}
_ => {}
}
}
let nap = STATUS_READ_INTERVAL.min(run_interval);
tokio::time::sleep(
nap.min(deadline.saturating_duration_since(tokio::time::Instant::now())),
)
.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),
(Duration::from_secs(2), Duration::from_secs(60))
);
}
#[test]
fn poll_plan_uses_interval_and_honors_start_period() {
let (run, budget) = super::health_poll_plan(Some("10s"), Some("60s"), Some(3));
assert_eq!(
(run, budget),
(Duration::from_secs(10), Duration::from_secs(90))
);
}
#[test]
fn poll_plan_honours_a_sub_second_interval() {
let (run, _) = super::health_poll_plan(Some("500ms"), None, Some(5));
assert_eq!(run, Duration::from_millis(500));
}
#[test]
fn poll_plan_floors_a_pathological_interval() {
let (run, _) = super::health_poll_plan(Some("1ms"), None, Some(5));
assert_eq!(run, super::MIN_RUN_INTERVAL);
}
#[test]
fn the_status_read_is_never_slower_than_the_default_run() {
assert!(super::STATUS_READ_INTERVAL < Duration::from_secs(2));
}
#[test]
fn budget_without_wait_timeout_uses_the_plan() {
let plan = Duration::from_secs(60);
assert_eq!(
super::effective_budget(Duration::from_secs(2), plan, None),
plan
);
}
#[test]
fn budget_extends_to_cover_wait_timeout() {
let b = super::effective_budget(
Duration::from_secs(10),
Duration::from_secs(10),
Some(Duration::from_secs(120)),
);
assert!(b > Duration::from_secs(120), "{b:?}");
}
#[test]
fn budget_keeps_the_larger_plan() {
let b = super::effective_budget(
Duration::from_secs(2),
Duration::from_secs(200),
Some(Duration::from_secs(10)),
);
assert_eq!(b, Duration::from_secs(200));
}
#[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
));
}
}