#![allow(clippy::doc_markdown)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::return_self_not_must_use)]
#![allow(clippy::needless_borrows_for_generic_args)]
#![allow(clippy::redundant_closure_for_method_calls)]
#![allow(clippy::inefficient_to_string)]
use crate::{DockerCommand, RunCommand};
use async_trait::async_trait;
use std::collections::HashMap;
use tracing::{debug, error, info, trace, warn};
#[cfg(any(
feature = "template-redis",
feature = "template-redis-cluster",
feature = "template-redis-enterprise"
))]
pub mod redis;
#[cfg(any(
feature = "template-postgres",
feature = "template-mysql",
feature = "template-mongodb"
))]
pub mod database;
#[cfg(feature = "template-nginx")]
pub mod web;
pub type Result<T> = std::result::Result<T, TemplateError>;
#[derive(Debug, thiserror::Error)]
pub enum TemplateError {
#[error("Docker command failed: {0}")]
DockerError(#[from] crate::Error),
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
#[error("Template already running: {0}")]
AlreadyRunning(String),
#[error("Template not running: {0}")]
NotRunning(String),
#[error("Timeout: {0}")]
Timeout(String),
}
#[derive(Debug, Clone)]
pub struct TemplateConfig {
pub name: String,
pub image: String,
pub tag: String,
pub ports: Vec<(u16, u16)>,
pub env: HashMap<String, String>,
pub volumes: Vec<VolumeMount>,
pub network: Option<String>,
pub health_check: Option<HealthCheck>,
pub auto_remove: bool,
pub memory_limit: Option<String>,
pub cpu_limit: Option<String>,
pub platform: Option<String>,
}
#[derive(Debug, Clone)]
pub struct VolumeMount {
pub source: String,
pub target: String,
pub read_only: bool,
}
#[derive(Debug, Clone)]
pub struct HealthCheck {
pub test: Vec<String>,
pub interval: String,
pub timeout: String,
pub retries: i32,
pub start_period: String,
}
#[async_trait]
pub trait Template: Send + Sync {
fn name(&self) -> &str;
fn config(&self) -> &TemplateConfig;
fn config_mut(&mut self) -> &mut TemplateConfig;
fn build_command(&self) -> RunCommand {
let config = self.config();
let mut cmd = RunCommand::new(format!("{}:{}", config.image, config.tag))
.name(&config.name)
.detach();
for (host, container) in &config.ports {
cmd = cmd.port(*host, *container);
}
for (key, value) in &config.env {
cmd = cmd.env(key, value);
}
for mount in &config.volumes {
if mount.read_only {
cmd = cmd.volume_ro(&mount.source, &mount.target);
} else {
cmd = cmd.volume(&mount.source, &mount.target);
}
}
if let Some(network) = &config.network {
cmd = cmd.network(network);
}
if let Some(health) = &config.health_check {
cmd = cmd
.health_cmd(&health.test.join(" "))
.health_interval(&health.interval)
.health_timeout(&health.timeout)
.health_retries(health.retries)
.health_start_period(&health.start_period);
}
if let Some(memory) = &config.memory_limit {
cmd = cmd.memory(memory);
}
if let Some(cpu) = &config.cpu_limit {
cmd = cmd.cpus(cpu);
}
if let Some(platform) = &config.platform {
cmd = cmd.platform(platform);
}
if config.auto_remove {
cmd = cmd.remove();
}
cmd
}
async fn start(&self) -> Result<String> {
let config = self.config();
info!(
template = %config.name,
image = %config.image,
tag = %config.tag,
"starting container from template"
);
let output = self.build_command().execute().await.map_err(|e| {
error!(
template = %config.name,
error = %e,
"failed to start container"
);
e
})?;
info!(
template = %config.name,
container_id = %output.0,
"container started successfully"
);
Ok(output.0)
}
async fn start_and_wait(&self) -> Result<String> {
let config = self.config();
info!(
template = %config.name,
"starting container and waiting for ready"
);
let container_id = self.start().await?;
self.wait_for_ready().await?;
info!(
template = %config.name,
container_id = %container_id,
"container started and ready"
);
Ok(container_id)
}
async fn stop(&self) -> Result<()> {
use crate::StopCommand;
let name = self.config().name.as_str();
info!(template = %name, "stopping container");
StopCommand::new(name).execute().await.map_err(|e| {
error!(template = %name, error = %e, "failed to stop container");
e
})?;
debug!(template = %name, "container stopped");
Ok(())
}
async fn remove(&self) -> Result<()> {
use crate::RmCommand;
let name = self.config().name.as_str();
info!(template = %name, "removing container");
RmCommand::new(name)
.force()
.volumes()
.execute()
.await
.map_err(|e| {
error!(template = %name, error = %e, "failed to remove container");
e
})?;
debug!(template = %name, "container removed");
Ok(())
}
async fn is_running(&self) -> Result<bool> {
use crate::PsCommand;
let name = &self.config().name;
let output = PsCommand::new()
.filter(format!("name={name}"))
.quiet()
.execute()
.await?;
let running = !output.stdout.trim().is_empty();
trace!(template = %name, running = running, "checked container running status");
Ok(running)
}
async fn logs(&self, follow: bool, tail: Option<&str>) -> Result<crate::CommandOutput> {
use crate::LogsCommand;
let mut cmd = LogsCommand::new(&self.config().name);
if follow {
cmd = cmd.follow();
}
if let Some(lines) = tail {
cmd = cmd.tail(lines);
}
cmd.execute().await.map_err(Into::into)
}
async fn exec(&self, command: Vec<&str>) -> Result<crate::ExecOutput> {
use crate::ExecCommand;
let cmd_vec: Vec<String> = command.iter().map(|s| s.to_string()).collect();
let cmd = ExecCommand::new(&self.config().name, cmd_vec);
cmd.execute().await.map_err(Into::into)
}
#[allow(clippy::too_many_lines)]
async fn wait_for_ready(&self) -> Result<()> {
use std::time::Duration;
use tokio::time::{sleep, timeout, Instant};
let name = &self.config().name;
let has_health_check = self.config().health_check.is_some();
let wait_timeout = Duration::from_secs(60);
let check_interval = Duration::from_millis(500);
info!(
template = %name,
timeout_secs = wait_timeout.as_secs(),
has_health_check = has_health_check,
"waiting for container to be ready"
);
let start_time = Instant::now();
let mut check_count = 0u32;
let result = timeout(wait_timeout, async {
loop {
check_count += 1;
let running = self.is_running().await.unwrap_or(false);
if !running {
trace!(
template = %name,
check = check_count,
"container not yet running, waiting"
);
sleep(check_interval).await;
continue;
}
if has_health_check {
use crate::InspectCommand;
if let Ok(inspect) = InspectCommand::new(name).execute().await {
if let Ok(containers) =
serde_json::from_str::<serde_json::Value>(&inspect.stdout)
{
if let Some(first) = containers.as_array().and_then(|arr| arr.first()) {
if let Some(state) = first.get("State") {
if let Some(health) = state.get("Health") {
if let Some(status) =
health.get("Status").and_then(|s| s.as_str())
{
trace!(
template = %name,
check = check_count,
health_status = %status,
"health check status"
);
if status == "healthy" {
#[allow(clippy::cast_possible_truncation)]
let elapsed_ms = start_time.elapsed().as_millis() as u64;
debug!(
template = %name,
checks = check_count,
elapsed_ms = elapsed_ms,
"container healthy"
);
return Ok(());
} else if status == "unhealthy" {
warn!(
template = %name,
"container reported unhealthy, continuing to wait"
);
}
}
} else if let Some(running) =
state.get("Running").and_then(|r| r.as_bool())
{
if running {
#[allow(clippy::cast_possible_truncation)]
let elapsed_ms = start_time.elapsed().as_millis() as u64;
debug!(
template = %name,
checks = check_count,
elapsed_ms = elapsed_ms,
"container running (no health check)"
);
return Ok(());
}
}
}
}
}
}
} else {
#[allow(clippy::cast_possible_truncation)]
let elapsed_ms = start_time.elapsed().as_millis() as u64;
debug!(
template = %name,
checks = check_count,
elapsed_ms = elapsed_ms,
"container running (no health check configured)"
);
return Ok(());
}
sleep(check_interval).await;
}
})
.await;
if let Ok(inner) = result {
inner
} else {
error!(
template = %name,
timeout_secs = wait_timeout.as_secs(),
checks = check_count,
"container failed to become ready within timeout"
);
Err(TemplateError::InvalidConfig(format!(
"Container {name} failed to become ready within timeout"
)))
}
}
}
pub struct TemplateBuilder {
config: TemplateConfig,
}
impl TemplateBuilder {
pub fn new(name: impl Into<String>, image: impl Into<String>) -> Self {
Self {
config: TemplateConfig {
name: name.into(),
image: image.into(),
tag: "latest".to_string(),
ports: Vec::new(),
env: HashMap::new(),
volumes: Vec::new(),
network: None,
health_check: None,
auto_remove: false,
memory_limit: None,
cpu_limit: None,
platform: None,
},
}
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.config.tag = tag.into();
self
}
pub fn port(mut self, host: u16, container: u16) -> Self {
self.config.ports.push((host, container));
self
}
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.config.env.insert(key.into(), value.into());
self
}
pub fn volume(mut self, source: impl Into<String>, target: impl Into<String>) -> Self {
self.config.volumes.push(VolumeMount {
source: source.into(),
target: target.into(),
read_only: false,
});
self
}
pub fn volume_ro(mut self, source: impl Into<String>, target: impl Into<String>) -> Self {
self.config.volumes.push(VolumeMount {
source: source.into(),
target: target.into(),
read_only: true,
});
self
}
pub fn network(mut self, network: impl Into<String>) -> Self {
self.config.network = Some(network.into());
self
}
pub fn auto_remove(mut self) -> Self {
self.config.auto_remove = true;
self
}
pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
self.config.memory_limit = Some(limit.into());
self
}
pub fn cpu_limit(mut self, limit: impl Into<String>) -> Self {
self.config.cpu_limit = Some(limit.into());
self
}
pub fn build(self) -> CustomTemplate {
CustomTemplate {
config: self.config,
}
}
}
pub struct CustomTemplate {
config: TemplateConfig,
}
#[async_trait]
impl Template for CustomTemplate {
fn name(&self) -> &str {
&self.config.name
}
fn config(&self) -> &TemplateConfig {
&self.config
}
fn config_mut(&mut self) -> &mut TemplateConfig {
&mut self.config
}
}
pub trait HasConnectionString {
fn connection_string(&self) -> String;
}
#[cfg(feature = "template-redis")]
pub use redis::RedisTemplate;
#[cfg(feature = "template-redis-cluster")]
pub use redis::{ClusterInfo, NodeInfo, NodeRole, RedisClusterConnection, RedisClusterTemplate};
#[cfg(feature = "template-redis-enterprise")]
pub use redis::{RedisEnterpriseConnectionInfo, RedisEnterpriseTemplate};
#[cfg(feature = "template-postgres")]
pub use database::postgres::{PostgresConnectionString, PostgresTemplate};
#[cfg(feature = "template-mysql")]
pub use database::mysql::{MysqlConnectionString, MysqlTemplate};
#[cfg(feature = "template-mongodb")]
pub use database::mongodb::{MongodbConnectionString, MongodbTemplate};
#[cfg(feature = "template-nginx")]
pub use web::nginx::NginxTemplate;