use crate::config::{DockerConfig, HealthCheck};
use crate::env::{fatal, log};
use std::net::{TcpStream, ToSocketAddrs};
use std::path::Path;
use std::process::Command;
use std::thread;
use std::time::Duration;
pub fn compose_up(root: &Path, config: &DockerConfig) {
log("Starting Docker services...");
let status = Command::new("docker")
.args(["compose", "-f", &config.compose_file, "up", "-d"])
.current_dir(root)
.status();
match status {
Ok(s) if s.success() => {}
Ok(s) => fatal(&format!("docker compose up exited with {s}")),
Err(e) => fatal(&format!("Failed to run docker compose: {e}")),
}
}
pub fn compose_down(root: &Path, config: &DockerConfig) {
log("Stopping Docker services...");
let _ = Command::new("docker")
.args(["compose", "-f", &config.compose_file, "down"])
.current_dir(root)
.status();
log("Docker services stopped.");
}
pub fn wait_for_health(root: &Path, checks: &[HealthCheck]) {
for check in checks {
log(&format!("Waiting for {}...", check.name));
let last_err = wait_for(check.timeout, || run_check(root, check));
if let Some(reason) = last_err {
let desc = check_description(check);
fatal(&format!(
"\u{2717} health check failed: {} ({}) \u{2014} {}",
check.name, desc, reason
));
}
log(&format!("{} ready.", check.name));
}
}
fn check_description(check: &HealthCheck) -> String {
if let Some(cmd) = &check.cmd {
format!("cmd: {}", cmd.join(" "))
} else if let Some(url) = &check.url {
format!("url: {url}")
} else if let Some(addr) = &check.tcp {
format!("tcp: {addr}")
} else {
"none".to_string()
}
}
fn run_check(root: &Path, check: &HealthCheck) -> Result<(), String> {
if let Some(cmd) = &check.cmd {
return run_cmd_check(root, cmd);
}
if let Some(url) = &check.url {
return run_url_check(url);
}
if let Some(addr) = &check.tcp {
return run_tcp_check(addr);
}
Ok(())
}
fn run_cmd_check(root: &Path, cmd: &[String]) -> Result<(), String> {
if cmd.is_empty() {
return Ok(());
}
match Command::new(&cmd[0])
.args(&cmd[1..])
.current_dir(root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output()
{
Ok(output) if output.status.success() => Ok(()),
Ok(output) => {
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
let first_line = stderr.lines().next().unwrap_or("").trim();
if first_line.is_empty() {
Err(format!("exit code {code}"))
} else {
let msg: String = first_line.chars().take(200).collect();
let msg = if first_line.len() > msg.len() {
format!("{msg}...")
} else {
msg
};
Err(format!("exit code {code}: {msg}"))
}
}
Err(e) => Err(format!("failed to execute: {e}")),
}
}
fn run_url_check(url: &str) -> Result<(), String> {
match ureq::get(url).call() {
Ok(resp) => {
if resp.status().is_success() {
Ok(())
} else {
Err(format!("HTTP {}", resp.status()))
}
}
Err(e) => Err(e.to_string()),
}
}
fn run_tcp_check(addr: &str) -> Result<(), String> {
let socket_addr = addr
.to_socket_addrs()
.map_err(|e| format!("invalid address: {e}"))?
.next()
.ok_or_else(|| "address resolved to nothing".to_string())?;
TcpStream::connect_timeout(&socket_addr, Duration::from_secs(2))
.map(|_| ())
.map_err(|e| e.to_string())
}
fn wait_for(timeout_secs: u64, check: impl Fn() -> Result<(), String>) -> Option<String> {
let mut last_err = None;
for _ in 0..timeout_secs {
match check() {
Ok(()) => return None,
Err(e) => last_err = Some(e),
}
thread::sleep(Duration::from_secs(1));
}
Some(last_err.unwrap_or_else(|| "timed out".to_string()))
}