use std::{
path::PathBuf,
process::{Child, Command},
time::Duration,
};
use crate::base::{
error::{TestError, TestsResult},
tee_writer::TeeWriter,
};
pub struct Compose {
file: String,
tee: TeeWriter,
timeout: Duration,
child: Option<Child>,
}
impl Compose {
pub fn new(file: impl Into<PathBuf>, tee: TeeWriter, timeout: Duration) -> Self {
let file: PathBuf = file.into();
Self {
file: file.to_string_lossy().into_owned(),
tee,
timeout,
child: None,
}
}
pub async fn up(self) -> TestsResult<Self> {
tracing::info!("Starting docker compose: {}", self.file);
let mut child = Command::new("docker")
.args(["compose", "-f", &self.file, "up", "-d"])
.stdout(self.tee.clone())
.stderr(self.tee.clone())
.spawn()
.map_err(TestError::Io)?;
let status = child.wait().map_err(TestError::Io)?;
if !status.success() {
return Err(TestError::Setup(format!(
"Failed to start docker compose: {}",
self.file
)));
}
self.wait_healthy().await?;
Ok(self)
}
async fn wait_healthy(&self) -> TestsResult<()> {
let start = std::time::Instant::now();
loop {
if start.elapsed() > self.timeout {
return Err(TestError::Timeout("Compose services not healthy".into()));
}
let output = Command::new("docker")
.args(["compose", "-f", &self.file, "ps", "--format", "json"])
.output()
.map_err(TestError::Io)?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let all_healthy = stdout
.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.all(|s| {
let state = s["State"].as_str().unwrap_or("");
let health = s["Health"].as_str().unwrap_or("");
state == "running" && (health.is_empty() || health == "healthy")
});
if all_healthy && !stdout.is_empty() {
return Ok(());
}
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
pub fn down(&mut self) {
let _ = Command::new("docker")
.args(["compose", "-f", &self.file, "down", "-v"])
.stdout(self.tee.clone())
.stderr(self.tee.clone())
.status();
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}