use super::types::{DockerManager, ServiceConfig};
use crate::DuckError;
use crate::container::environment::detect_runtime_environment;
use anyhow::Result;
use docker_compose_types as dct;
use quick_cache::sync::Cache;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use tracing::{debug, info, warn};
#[derive(Debug, Clone)]
struct CacheEntry {
config: dct::Compose,
timestamp: u64,
}
static COMPOSE_CACHE: once_cell::sync::Lazy<Cache<(String, String), CacheEntry>> =
once_cell::sync::Lazy::new(|| {
Cache::new(100) });
impl DockerManager {
pub fn with_project<P: AsRef<Path>>(
compose_file: P,
env_file: P,
project_name: Option<String>,
) -> Result<Self> {
let compose_file = compose_file.as_ref().to_path_buf();
let env_file = env_file.as_ref().to_path_buf();
let runtime_env = detect_runtime_environment();
let compose_config = if compose_file.exists() {
Some(load_compose_config_with_env(&compose_file, &env_file)?)
} else {
warn!("docker-compose file does not exist");
None
};
if compose_config.is_none() {
info!("Compose configuration not loaded; this may be the first deployment and docker directory is missing");
}
Ok(Self {
compose_file,
env_file,
compose_config,
project_name,
runtime_env,
})
}
pub fn compose_file_exists(&self) -> bool {
self.compose_file.exists()
}
pub fn get_compose_file(&self) -> &Path {
&self.compose_file
}
pub fn get_env_file(&self) -> &Path {
&self.env_file
}
pub fn load_compose_config(&self) -> Result<dct::Compose> {
use std::time::{SystemTime, UNIX_EPOCH};
let cache_key = (
self.compose_file.display().to_string(),
self.env_file.display().to_string(),
);
if let Some(cached) = COMPOSE_CACHE.get(&cache_key) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if now - cached.timestamp < 30 {
debug!("Loaded docker-compose config from cache");
return Ok(cached.config.clone());
} else {
debug!("Cache expired, reloading config");
}
}
debug!("Reloading docker-compose config");
let compose_config = load_compose_config_with_env(&self.compose_file, &self.env_file)?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
COMPOSE_CACHE.insert(
cache_key,
CacheEntry {
config: compose_config.clone(),
timestamp,
},
);
Ok(compose_config)
}
pub async fn is_oneshot_service(&self, service_name: &str) -> Result<bool> {
let services = &self.load_compose_config()?.services;
if let Some(service_opt) = services.0.get(service_name) {
if let Some(service) = service_opt {
if let Some(restart_policy) = &service.restart {
let policy = restart_policy.to_string();
if policy == "no" || policy == "false" {
return Ok(true);
}
if policy == "always" || policy == "unless-stopped" || policy == "on-failure" {
return Ok(false);
}
}
}
Ok(false)
} else {
Err(anyhow::anyhow!("Service does not exist: {service_name}"))
}
}
pub async fn parse_service_config(&self, service_name: &str) -> Result<ServiceConfig> {
let services = &self.load_compose_config()?.services;
let service = services
.0
.get(service_name)
.ok_or_else(|| DuckError::Docker(format!("Service not found: {service_name}")))?;
let restart = service.as_ref().and_then(|s| s.restart.clone());
Ok(ServiceConfig { restart })
}
pub async fn get_compose_service_names(&self) -> Result<HashSet<String>> {
let services = &self.load_compose_config()?.services;
let mut service_names = HashSet::new();
for (service_name, _) in services.0.iter() {
service_names.insert(service_name.to_string());
}
Ok(service_names)
}
pub fn get_compose_project_name(&self) -> String {
if let Some(ref project_name) = self.project_name {
info!("Using provided project name: {}", project_name);
unsafe {
std::env::set_var("COMPOSE_PROJECT_NAME", project_name);
}
return project_name.clone();
}
if let Ok(compose_config) = self.load_compose_config() {
if let Some(project_name) = compose_config.name {
info!("Read project name from compose file: {}", project_name);
unsafe {
std::env::set_var("COMPOSE_PROJECT_NAME", &project_name);
}
return project_name;
}
}
let default_name = "docker".to_string();
unsafe {
std::env::set_var("COMPOSE_PROJECT_NAME", &default_name);
}
default_name
}
pub fn generate_compose_container_patterns(&self, service_name: &str) -> Vec<String> {
let project_name = self.get_compose_project_name();
vec![
format!("{project_name}_{service_name}_1"),
format!("{project_name}-{service_name}-1"),
format!("{project_name}_{service_name}"),
format!("{project_name}-{service_name}"),
service_name.to_string(),
]
}
}
pub fn load_compose_config_with_env(compose_path: &Path, env_path: &Path) -> Result<dct::Compose> {
dotenvy::from_path_override(env_path).ok();
let content = fs::read_to_string(compose_path)
.map_err(|e| DuckError::Docker(format!("Failed to read compose file: {e}")))?;
let context = |s: &str| Ok(std::env::var(s).ok());
let expanded_content = shellexpand::env_with_context(&content, context).map_err(
|e: shellexpand::LookupError<std::convert::Infallible>| {
DuckError::Docker(format!("Failed to expand env vars: {e}"))
},
)?;
let compose_config: dct::Compose = serde_yaml::from_str(&expanded_content).map_err(|e| {
DuckError::Docker(format!("Failed to parse compose file with serde_yaml: {e}"))
})?;
debug!("Successfully parsed docker-compose.yml!");
let services = &compose_config.services;
info!("Found {} services:", services.0.len());
for (name, service_opt) in services.0.iter() {
let image = service_opt
.as_ref()
.and_then(|s| s.image.as_deref())
.unwrap_or("N/A");
info!(" - Service: {}, Image: {}", name, image);
}
Ok(compose_config)
}