use anyhow::{Result, anyhow};
use bollard::Docker;
use bollard::container::LogOutput;
use bollard::models::{
ContainerCreateBody, ContainerInspectResponse, ContainerStateStatusEnum, HostConfig,
PortBinding,
};
use bollard::query_parameters::{
CreateContainerOptions, CreateImageOptions, InspectContainerOptions, ListContainersOptions,
LogsOptionsBuilder, RemoveContainerOptions, StartContainerOptions, StopContainerOptions,
};
use futures_util::StreamExt;
use std::collections::HashMap;
use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, info, warn};
use crate::config::types::ShadowDockerConfig;
pub struct DockerManager {
docker: Docker,
}
#[derive(Debug, Clone)]
pub struct ContainerInfo {
pub id: String,
pub host: String,
pub port: u16,
pub database: String,
pub username: String,
pub password: String,
}
impl ContainerInfo {
pub fn connection_string(&self) -> String {
format!(
"postgres://{}:{}@{}:{}/{}?sslmode=disable",
self.username, self.password, self.host, self.port, self.database
)
}
}
pub struct ShadowDatabase {
container_info: ContainerInfo,
auto_cleanup: bool,
}
impl ShadowDatabase {
#[allow(dead_code)] pub fn connection_string(&self) -> String {
self.container_info.connection_string()
}
pub fn into_connection_string(mut self) -> String {
self.auto_cleanup = false;
self.container_info.connection_string()
}
}
impl Drop for ShadowDatabase {
fn drop(&mut self) {
if !self.auto_cleanup {
return;
}
let container_id = self.container_info.id.clone();
unregister_container(&container_id);
let cleanup_result = std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
debug!("Failed to create runtime for cleanup: {}", e);
return;
}
};
rt.block_on(async {
match DockerManager::new().await {
Ok(manager) => {
if let Err(e) = manager.stop_container(&container_id, true).await {
let error_msg = e.to_string();
if !error_msg.contains("404")
&& !error_msg.contains("No such container")
{
debug!("Failed to cleanup shadow database {}: {}", container_id, e);
}
} else {
debug!("Cleaned up shadow database: {}", container_id);
}
}
Err(e) => {
debug!("Failed to create Docker manager for cleanup: {}", e);
}
}
});
});
let _ = cleanup_result.join();
}
}
impl DockerManager {
pub async fn is_available_verbose() -> (bool, String) {
match Self::try_connect_verbose().await {
Ok((_, debug_info)) => (true, debug_info),
Err(e) => (false, format!("Docker not available: {}", e)),
}
}
pub async fn new() -> Result<Self> {
const MAX_RETRIES: u32 = 5;
const RETRY_DELAY_MS: u64 = 200;
for attempt in 0..=MAX_RETRIES {
match Self::try_connect().await {
Ok(docker_manager) => {
if attempt > 0 {
println!(
"✅ Connected to Docker (after {} retry{})",
attempt,
if attempt == 1 { "" } else { "ies" }
);
}
return Ok(docker_manager);
}
Err(_e) => {
if attempt < MAX_RETRIES {
if attempt == 0 {
println!("🔄 Docker not ready, retrying...");
}
tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
}
}
}
}
let (_, debug_info) = Self::is_available_verbose().await;
Err(anyhow!(
"Failed to connect to Docker after {} attempts.\n\n{}\n💡 Troubleshooting:\n • Make sure Docker is running\n • On macOS: Try 'export DOCKER_HOST=unix:///Users/$USER/.docker/run/docker.sock'\n • Check Docker Desktop settings",
MAX_RETRIES + 1,
debug_info
))
}
async fn try_connect() -> Result<Self> {
let socket_candidates = Self::get_docker_socket_candidates();
for (_description, socket_path) in socket_candidates {
if let Ok(docker) = Self::try_socket_path(&socket_path).await {
return Ok(Self { docker });
}
}
let docker = Docker::connect_with_local_defaults().map_err(|e| {
anyhow!(
"Failed to connect to Docker daemon after trying all socket paths: {}",
e
)
})?;
docker
.ping()
.await
.map_err(|e| anyhow!("Docker daemon not responding: {}", e))?;
Ok(Self { docker })
}
async fn try_connect_verbose() -> Result<(Self, String)> {
let mut debug_info = String::new();
debug_info.push_str("Docker socket detection:\n");
let socket_candidates = Self::get_docker_socket_candidates();
for (description, socket_path) in &socket_candidates {
debug_info.push_str(&format!(" • {}: ", description));
match Self::try_socket_path(socket_path).await {
Ok(docker) => {
debug_info.push_str(&format!("✅ Connected ({})\n", socket_path));
return Ok((Self { docker }, debug_info));
}
Err(e) => {
debug_info.push_str(&format!("❌ Failed - {}\n", e));
}
}
}
debug_info.push_str(" • Bollard default detection: ");
match Docker::connect_with_local_defaults() {
Ok(docker) => match docker.ping().await {
Ok(_) => {
debug_info.push_str("✅ Connected\n");
return Ok((Self { docker }, debug_info));
}
Err(e) => {
debug_info.push_str(&format!("❌ Failed to ping - {}\n", e));
}
},
Err(e) => {
debug_info.push_str(&format!("❌ Failed to connect - {}\n", e));
}
}
Err(anyhow!(
"Failed to connect to Docker daemon after trying all methods:\n{}",
debug_info
))
}
fn get_docker_socket_candidates() -> Vec<(String, String)> {
let mut candidates = Vec::new();
if let Ok(docker_host) = std::env::var("DOCKER_HOST") {
candidates.push(("DOCKER_HOST environment variable".to_string(), docker_host));
}
#[cfg(target_os = "macos")]
{
if let Ok(home) = std::env::var("HOME") {
let macos_socket = format!("unix://{}/.docker/run/docker.sock", home);
candidates.push(("macOS Docker Desktop".to_string(), macos_socket));
let colima_socket = format!("unix://{}/.colima/default/docker.sock", home);
candidates.push(("Colima".to_string(), colima_socket));
let orbstack_socket = format!("unix://{}/.orbstack/run/docker.sock", home);
candidates.push(("OrbStack".to_string(), orbstack_socket));
}
}
candidates.push((
"Standard Linux location".to_string(),
"unix:///var/run/docker.sock".to_string(),
));
candidates
}
async fn try_socket_path(socket_path: &str) -> Result<Docker> {
if let Some(socket_file) = socket_path.strip_prefix("unix://") {
let docker = Docker::connect_with_socket(
socket_file,
120, bollard::API_DEFAULT_VERSION,
)
.map_err(|e| anyhow!("Failed to connect to socket {}: {}", socket_path, e))?;
docker
.ping()
.await
.map_err(|e| anyhow!("Socket {} not responding: {}", socket_path, e))?;
Ok(docker)
} else {
Err(anyhow!("Unsupported socket protocol: {}", socket_path))
}
}
pub async fn start_shadow_database(
&self,
config: &ShadowDockerConfig,
) -> Result<ShadowDatabase> {
let container_name = config
.container_name
.clone()
.unwrap_or_else(|| format!("pgmt_shadow_{}", uuid::Uuid::new_v4().simple()));
debug!("🚀 Starting PostgreSQL container: {}", container_name);
if let Some(existing_info) = self
.find_existing_container(&container_name, config)
.await?
{
if self.is_container_healthy(&existing_info.id).await? {
debug!(
"Using existing healthy PostgreSQL container: {}",
container_name
);
if config.auto_cleanup {
register_container(existing_info.id.clone());
}
return Ok(ShadowDatabase {
container_info: existing_info,
auto_cleanup: config.auto_cleanup,
});
} else {
warn!(
"Existing container {} is unhealthy, removing",
container_name
);
self.remove_container(&existing_info.id, true).await?;
}
}
debug!("Starting new PostgreSQL container: {}", container_name);
let resolved_image = config.resolved_image();
let image_start = std::time::Instant::now();
self.ensure_image_available(&resolved_image).await?;
debug!("Image available after {:?}", image_start.elapsed());
let mut env_vars = Vec::new();
if !config.environment.contains_key("POSTGRES_DB") {
env_vars.push("POSTGRES_DB=pgmt_shadow".to_string());
}
if !config.environment.contains_key("POSTGRES_USER") {
env_vars.push("POSTGRES_USER=postgres".to_string());
}
if !config.environment.contains_key("POSTGRES_PASSWORD") {
env_vars.push("POSTGRES_PASSWORD=pgmt_shadow_password".to_string());
}
for (key, value) in &config.environment {
env_vars.push(format!("{}={}", key, value));
}
let mut port_bindings = HashMap::new();
port_bindings.insert(
"5432/tcp".to_string(),
Some(vec![PortBinding {
host_ip: Some("127.0.0.1".to_string()),
host_port: None, }]),
);
let host_config = HostConfig {
port_bindings: Some(port_bindings),
..Default::default()
};
let container_config = ContainerCreateBody {
image: Some(resolved_image.clone()),
env: Some(env_vars),
host_config: Some(host_config),
..Default::default()
};
let create_start = std::time::Instant::now();
let create_options = CreateContainerOptions {
name: Some(container_name.clone()),
..Default::default()
};
let container = self
.docker
.create_container(Some(create_options), container_config)
.await
.map_err(|e| anyhow!("Failed to create container: {}", e))?;
debug!("Container created after {:?}", create_start.elapsed());
let start_container_time = std::time::Instant::now();
if let Err(e) = self
.docker
.start_container(&container.id, None::<StartContainerOptions>)
.await
{
let _ = self.remove_container(&container.id, true).await;
return Err(anyhow!("Failed to start container: {}", e));
}
debug!(
"Container started after {:?}",
start_container_time.elapsed()
);
let inspect_result = self
.docker
.inspect_container(&container.id, None::<InspectContainerOptions>)
.await
.map_err(|e| anyhow!("Failed to inspect container: {}", e))?;
let host_port = self.extract_host_port(&inspect_result)?;
debug!("Docker assigned port: {}", host_port);
let readiness_start = std::time::Instant::now();
if let Err(readiness_err) = self.wait_for_postgres_ready(&container.id).await {
let logs = self.fetch_container_logs(&container.id).await;
let keep_on_failure =
std::env::var("PGMT_KEEP_SHADOW_ON_FAILURE").is_ok_and(|v| !v.is_empty());
if keep_on_failure {
return Err(anyhow!(
"{readiness_err}\n\n\
Container logs (last 50 lines):\n{logs}\n\n\
The container has been kept alive for debugging:\n \
docker logs {container_name}\n \
docker exec -it {container_name} bash\n \
docker rm -f {container_name}"
));
} else {
let _ = self.remove_container(&container.id, true).await;
return Err(anyhow!(
"{readiness_err}\n\n\
Container logs (last 50 lines):\n{logs}\n\n\
Tip: Re-run with PGMT_KEEP_SHADOW_ON_FAILURE=1 to keep the container alive for debugging."
));
}
}
debug!("PostgreSQL ready after {:?}", readiness_start.elapsed());
let database = config
.environment
.get("POSTGRES_DB")
.cloned()
.unwrap_or_else(|| "pgmt_shadow".to_string());
let username = config
.environment
.get("POSTGRES_USER")
.cloned()
.unwrap_or_else(|| "postgres".to_string());
let password = config
.environment
.get("POSTGRES_PASSWORD")
.cloned()
.unwrap_or_else(|| "pgmt_shadow_password".to_string());
let container_info = ContainerInfo {
id: container.id.clone(),
host: "127.0.0.1".to_string(),
port: host_port,
database,
username,
password,
};
if config.auto_cleanup {
register_container(container.id.clone());
}
info!(
"PostgreSQL container ready: {}",
container_info.connection_string()
);
Ok(ShadowDatabase {
container_info,
auto_cleanup: config.auto_cleanup,
})
}
pub async fn stop_container(&self, container_id: &str, remove: bool) -> Result<()> {
let stop_result = self
.docker
.stop_container(container_id, None::<StopContainerOptions>)
.await;
match stop_result {
Ok(()) => {
if remove {
self.remove_container(container_id, false).await?;
}
}
Err(ref e) => {
let error_msg = e.to_string();
let is_not_found =
error_msg.contains("404") || error_msg.contains("No such container");
if is_not_found {
unregister_container(container_id);
return Err(anyhow!("Failed to stop container: {}", e));
}
if remove {
self.remove_container(container_id, true).await?;
unregister_container(container_id);
return Ok(());
}
unregister_container(container_id);
return Err(anyhow!("Failed to stop container: {}", e));
}
}
unregister_container(container_id);
Ok(())
}
async fn remove_container(&self, container_id: &str, force: bool) -> Result<()> {
let remove_options = RemoveContainerOptions {
force,
..Default::default()
};
self.docker
.remove_container(container_id, Some(remove_options))
.await
.map_err(|e| anyhow!("Failed to remove container: {}", e))?;
Ok(())
}
async fn fetch_container_logs(&self, container_id: &str) -> String {
let options = LogsOptionsBuilder::new()
.stdout(true)
.stderr(true)
.tail("50")
.build();
let log_stream = self.docker.logs(container_id, Some(options));
match tokio::time::timeout(
Duration::from_secs(3),
log_stream.collect::<Vec<Result<LogOutput, _>>>(),
)
.await
{
Ok(results) => {
let lines: Vec<String> = results
.into_iter()
.filter_map(|r| r.ok())
.map(|output| output.to_string())
.collect();
if lines.is_empty() {
"(no logs available)".to_string()
} else {
lines.join("")
}
}
Err(_) => "(timed out fetching container logs)".to_string(),
}
}
async fn find_existing_container(
&self,
name: &str,
_config: &ShadowDockerConfig,
) -> Result<Option<ContainerInfo>> {
let list_options = ListContainersOptions {
all: true,
filters: Some({
let mut filters = HashMap::new();
filters.insert("name".to_string(), vec![name.to_string()]);
filters
}),
..Default::default()
};
let containers = self
.docker
.list_containers(Some(list_options))
.await
.map_err(|e| anyhow!("Failed to list containers: {}", e))?;
if let Some(container) = containers.first()
&& let (Some(id), Some(names)) = (&container.id, &container.names)
&& let Some(_container_name) = names.first()
{
if let Some(ports) = &container.ports {
for port in ports {
if port.private_port == 5432 && port.public_port.is_some() {
return Ok(Some(ContainerInfo {
id: id.clone(),
host: "127.0.0.1".to_string(),
port: port.public_port.unwrap(),
database: "pgmt_shadow".to_string(),
username: "postgres".to_string(),
password: "pgmt_shadow_password".to_string(), }));
}
}
}
}
Ok(None)
}
async fn is_container_healthy(&self, container_id: &str) -> Result<bool> {
let inspect = self
.docker
.inspect_container(container_id, None::<InspectContainerOptions>)
.await
.map_err(|e| anyhow!("Failed to inspect container: {}", e))?;
if let Some(ref state) = inspect.state {
if state.status != Some(ContainerStateStatusEnum::RUNNING) {
return Ok(false);
}
} else {
return Ok(false);
}
if let Some(container_info) =
self.extract_container_info_from_inspect(&inspect, container_id)?
{
match self.test_postgres_connection(&container_info).await {
Ok(()) => Ok(true),
Err(_) => Ok(false),
}
} else {
Ok(false)
}
}
fn extract_container_info_from_inspect(
&self,
inspect: &ContainerInspectResponse,
container_id: &str,
) -> Result<Option<ContainerInfo>> {
let password = if let Some(config) = &inspect.config {
if let Some(env_vars) = &config.env {
env_vars
.iter()
.find(|env| env.starts_with("POSTGRES_PASSWORD="))
.and_then(|env| env.strip_prefix("POSTGRES_PASSWORD="))
.unwrap_or("pgmt_shadow_password")
.to_string()
} else {
"pgmt_shadow_password".to_string()
}
} else {
"pgmt_shadow_password".to_string()
};
if let Some(network_settings) = &inspect.network_settings
&& let Some(ports) = &network_settings.ports
&& let Some(port_bindings) = ports.get("5432/tcp")
&& let Some(port_binding) = port_bindings.as_ref().and_then(|bindings| bindings.first())
&& let Some(host_port) = &port_binding.host_port
&& let Ok(port) = host_port.parse::<u16>()
{
return Ok(Some(ContainerInfo {
id: container_id.to_string(),
host: "127.0.0.1".to_string(),
port,
database: "pgmt_shadow".to_string(),
username: "postgres".to_string(),
password,
}));
}
Ok(None)
}
async fn test_postgres_connection(&self, container_info: &ContainerInfo) -> Result<()> {
debug!(
"🔌 Testing PostgreSQL connection to {}",
container_info.connection_string()
);
const MAX_READINESS_RETRIES: u32 = 10;
const READINESS_RETRY_DELAY_MS: u64 = 500;
let mut last_error = None;
for attempt in 0..=MAX_READINESS_RETRIES {
match Self::try_database_connection(container_info).await {
Ok(_) => {
if attempt > 0 {
debug!(
"✅ PostgreSQL connection successful after {} attempt{}",
attempt + 1,
if attempt == 0 { "" } else { "s" }
);
} else {
debug!("✅ PostgreSQL connection successful");
}
return Ok(());
}
Err(e) => {
debug!(
"❌ PostgreSQL connection failed (attempt {}): {}",
attempt + 1,
e
);
last_error = Some(e);
if attempt < MAX_READINESS_RETRIES {
if attempt == 0 {
debug!("⏳ Waiting for PostgreSQL to be ready...");
}
tokio::time::sleep(Duration::from_millis(READINESS_RETRY_DELAY_MS)).await;
}
}
}
}
Err(anyhow!(
"PostgreSQL not ready after {} attempts: {}",
MAX_READINESS_RETRIES + 1,
last_error.unwrap()
))
}
async fn try_database_connection(container_info: &ContainerInfo) -> Result<()> {
use sqlx::postgres::PgPoolOptions;
let connection_string = container_info.connection_string();
debug!("🔗 Attempting to connect to: {}", connection_string);
let pool = PgPoolOptions::new()
.acquire_timeout(Duration::from_secs(5))
.connect(&connection_string)
.await
.map_err(|e| anyhow!("Failed to connect to PostgreSQL: {}", e))?;
debug!("✅ Connection pool established");
sqlx::query("SELECT 1 as test")
.fetch_one(&pool)
.await
.map_err(|e| anyhow!("Database query test failed: {}", e))?;
debug!("✅ Basic query test passed");
sqlx::query("CREATE TEMPORARY TABLE pgmt_readiness_test (id INTEGER)")
.execute(&pool)
.await
.map_err(|e| anyhow!("Database write test failed: {}", e))?;
debug!("✅ Write permissions test passed");
pool.close().await;
debug!("✅ Connection closed successfully");
Ok(())
}
async fn ensure_image_available(&self, image: &str) -> Result<()> {
match self.docker.inspect_image(image).await {
Ok(_) => return Ok(()),
Err(_) => debug!("Pulling PostgreSQL image: {}", image),
}
let create_image_options = CreateImageOptions {
from_image: Some(image.to_string()),
..Default::default()
};
let mut pull_stream = self
.docker
.create_image(Some(create_image_options), None, None);
while let Some(result) = pull_stream.next().await {
if let Err(e) = result {
return Err(anyhow!("Failed to pull image: {}", e));
}
}
debug!("Successfully pulled image: {}", image);
Ok(())
}
fn extract_host_port(&self, inspect_result: &ContainerInspectResponse) -> Result<u16> {
let network_settings = inspect_result
.network_settings
.as_ref()
.ok_or_else(|| anyhow!("Container has no network settings"))?;
let ports = network_settings
.ports
.as_ref()
.ok_or_else(|| anyhow!("Container has no port mappings"))?;
let port_bindings = ports
.get("5432/tcp")
.ok_or_else(|| anyhow!("Container has no 5432/tcp port mapping"))?
.as_ref()
.ok_or_else(|| anyhow!("Port 5432/tcp is not bound"))?;
let port_binding = port_bindings
.first()
.ok_or_else(|| anyhow!("No port bindings found for 5432/tcp"))?;
let host_port_str = port_binding
.host_port
.as_ref()
.ok_or_else(|| anyhow!("Host port not set"))?;
host_port_str
.parse::<u16>()
.map_err(|e| anyhow!("Invalid host port '{}': {}", host_port_str, e))
}
async fn wait_for_postgres_ready(&self, container_id: &str) -> Result<()> {
let is_test_env = std::env::var("CARGO").is_ok()
&& (std::thread::current()
.name()
.is_some_and(|name| name.contains("test"))
|| std::env::var("RUST_TEST_THREADS").is_ok()
|| std::env::var("CARGO_PKG_NAME").is_ok_and(|name| name == "pgmt"));
let (max_attempts, retry_delay_ms) = if is_test_env {
debug!(
"🧪 Test environment detected - using optimized retry settings (25 attempts × 6s = 150s max)"
);
(25_u32, 1000_u64) } else {
debug!(
"🏭 Production environment - using standard retry settings (30 attempts × 7s = 210s max)"
);
(30_u32, 2000_u64) };
const INITIAL_DELAY_MS: u64 = 500;
sleep(Duration::from_millis(INITIAL_DELAY_MS)).await;
for attempt in 1..=max_attempts {
debug!("🔍 Readiness check attempt {}/{}", attempt, max_attempts);
let inspect = self
.docker
.inspect_container(container_id, None::<InspectContainerOptions>)
.await
.map_err(|e| anyhow!("Failed to inspect container: {}", e))?;
if let Some(ref state) = inspect.state {
match state.status {
Some(ContainerStateStatusEnum::EXITED)
| Some(ContainerStateStatusEnum::DEAD) => {
let exit_code = state.exit_code.unwrap_or(-1);
return Err(anyhow!(
"Shadow database container exited with code {}.",
exit_code,
));
}
_ => {}
}
}
if let Some(container_info) =
self.extract_container_info_from_inspect(&inspect, container_id)?
{
debug!(
"📋 Container connection info: {}:{}",
container_info.host, container_info.port
);
match Self::try_database_connection(&container_info).await {
Ok(()) => {
debug!(
"✅ PostgreSQL is ready to accept connections after {} attempt{}",
attempt,
if attempt == 1 { "" } else { "s" }
);
return Ok(());
}
Err(e) if attempt < max_attempts => {
debug!("❌ PostgreSQL not ready yet (attempt {}): {}", attempt, e);
sleep(Duration::from_millis(retry_delay_ms)).await;
}
Err(e) => {
return Err(anyhow!(
"PostgreSQL failed to become ready after {} attempts. Last error: {}",
max_attempts,
e
));
}
}
} else {
warn!(
"⚠️ Could not extract container connection info on attempt {}",
attempt
);
if attempt < max_attempts {
sleep(Duration::from_millis(retry_delay_ms)).await;
} else {
return Err(anyhow!(
"Could not extract container connection info after {} attempts",
max_attempts
));
}
}
}
unreachable!()
}
}
use once_cell::sync::Lazy;
use std::sync::{Arc, Mutex};
static CONTAINER_REGISTRY: Lazy<Arc<Mutex<Vec<String>>>> =
Lazy::new(|| Arc::new(Mutex::new(Vec::new())));
pub fn register_container(container_id: String) {
let mut registry = CONTAINER_REGISTRY.lock().unwrap();
registry.push(container_id);
}
pub fn unregister_container(container_id: &str) {
let mut registry = CONTAINER_REGISTRY.lock().unwrap();
registry.retain(|id| id != container_id);
}
pub async fn cleanup_all_containers() -> Result<()> {
let container_ids = {
let mut registry = CONTAINER_REGISTRY.lock().unwrap();
let ids = registry.clone();
registry.clear();
ids
};
if container_ids.is_empty() {
return Ok(());
}
info!(
"Cleaning up {} registered container(s)",
container_ids.len()
);
let mut cleanup_tasks = Vec::new();
for container_id in container_ids {
let id = container_id.clone();
let task = tokio::spawn(async move {
match DockerManager::new().await {
Ok(manager) => match manager.stop_container(&id, true).await {
Ok(()) => {
info!("Successfully cleaned up container: {}", id);
}
Err(e) => {
let error_msg = e.to_string();
if error_msg.contains("404") || error_msg.contains("No such container") {
debug!("Container {} already removed (404) - cleanup succeeded", id);
} else {
warn!("Failed to cleanup container {}: {}", id, e);
}
}
},
Err(e) => {
warn!(
"Failed to create Docker manager for cleanup of {}: {}",
id, e
);
}
}
});
cleanup_tasks.push(task);
}
const CLEANUP_TIMEOUT_SECS: u64 = 10;
let cleanup_future = futures_util::future::join_all(cleanup_tasks);
if tokio::time::timeout(
std::time::Duration::from_secs(CLEANUP_TIMEOUT_SECS),
cleanup_future,
)
.await
.is_err()
{
warn!(
"Container cleanup timed out after {} seconds",
CLEANUP_TIMEOUT_SECS
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_docker_socket_candidates() {
let candidates = DockerManager::get_docker_socket_candidates();
assert!(
!candidates.is_empty(),
"Should have at least one socket candidate"
);
let last_candidate = candidates.last().unwrap();
assert_eq!(last_candidate.1, "unix:///var/run/docker.sock");
#[cfg(target_os = "macos")]
{
let candidate_names: Vec<&String> = candidates.iter().map(|(name, _)| name).collect();
if std::env::var("HOME").is_ok() {
assert!(
candidate_names
.iter()
.any(|name| name.contains("Docker Desktop"))
);
assert!(candidate_names.iter().any(|name| name.contains("Colima")));
assert!(candidate_names.iter().any(|name| name.contains("OrbStack")));
}
}
if std::env::var("DOCKER_HOST").is_ok() {
let first_candidate = candidates.first().unwrap();
assert_eq!(first_candidate.0, "DOCKER_HOST environment variable");
}
}
#[tokio::test]
async fn test_verbose_availability() {
let (_is_available, debug_info) = DockerManager::is_available_verbose().await;
assert!(debug_info.contains("Docker socket detection"));
assert!(debug_info.contains("•"));
}
}