docker_wrapper/template/
mod.rs

1//! Docker template system for common container configurations
2//!
3//! This module provides pre-configured templates for common Docker setups,
4//! making it easy to spin up development environments with best practices.
5
6#![allow(clippy::doc_markdown)]
7#![allow(clippy::must_use_candidate)]
8#![allow(clippy::return_self_not_must_use)]
9#![allow(clippy::needless_borrows_for_generic_args)]
10#![allow(clippy::redundant_closure_for_method_calls)]
11#![allow(clippy::inefficient_to_string)]
12
13use crate::{DockerCommand, RunCommand};
14use async_trait::async_trait;
15use std::collections::HashMap;
16
17// Redis templates
18#[cfg(any(feature = "template-redis", feature = "template-redis-cluster"))]
19pub mod redis;
20
21// Database templates
22#[cfg(any(
23    feature = "template-postgres",
24    feature = "template-mysql",
25    feature = "template-mongodb"
26))]
27pub mod database;
28
29// Web server templates
30#[cfg(feature = "template-nginx")]
31pub mod web;
32
33/// Result type for template operations
34pub type Result<T> = std::result::Result<T, TemplateError>;
35
36/// Template-specific errors
37#[derive(Debug, thiserror::Error)]
38pub enum TemplateError {
39    /// Docker command execution failed
40    #[error("Docker command failed: {0}")]
41    DockerError(#[from] crate::Error),
42
43    /// Invalid template configuration provided
44    #[error("Invalid configuration: {0}")]
45    InvalidConfig(String),
46
47    /// Attempted to start a template that is already running
48    #[error("Template already running: {0}")]
49    AlreadyRunning(String),
50
51    /// Attempted to operate on a template that is not running
52    #[error("Template not running: {0}")]
53    NotRunning(String),
54}
55
56/// Configuration for a Docker template
57#[derive(Debug, Clone)]
58pub struct TemplateConfig {
59    /// Container name
60    pub name: String,
61
62    /// Image to use
63    pub image: String,
64
65    /// Image tag
66    pub tag: String,
67
68    /// Port mappings (host -> container)
69    pub ports: Vec<(u16, u16)>,
70
71    /// Environment variables
72    pub env: HashMap<String, String>,
73
74    /// Volume mounts
75    pub volumes: Vec<VolumeMount>,
76
77    /// Network to connect to
78    pub network: Option<String>,
79
80    /// Health check configuration
81    pub health_check: Option<HealthCheck>,
82
83    /// Whether to remove container on stop
84    pub auto_remove: bool,
85
86    /// Memory limit
87    pub memory_limit: Option<String>,
88
89    /// CPU limit
90    pub cpu_limit: Option<String>,
91
92    /// Platform specification (e.g., "linux/amd64", "linux/arm64")
93    pub platform: Option<String>,
94}
95
96/// Volume mount configuration
97#[derive(Debug, Clone)]
98pub struct VolumeMount {
99    /// Source (host path or volume name)
100    pub source: String,
101
102    /// Target (container path)
103    pub target: String,
104
105    /// Read-only mount
106    pub read_only: bool,
107}
108
109/// Health check configuration
110#[derive(Debug, Clone)]
111pub struct HealthCheck {
112    /// Command to run for health check
113    pub test: Vec<String>,
114
115    /// Time between checks
116    pub interval: String,
117
118    /// Maximum time to wait for check
119    pub timeout: String,
120
121    /// Number of retries before marking unhealthy
122    pub retries: i32,
123
124    /// Start period for container initialization
125    pub start_period: String,
126}
127
128/// Trait for Docker container templates
129#[async_trait]
130pub trait Template: Send + Sync {
131    /// Get the template name
132    fn name(&self) -> &str;
133
134    /// Get the template configuration
135    fn config(&self) -> &TemplateConfig;
136
137    /// Get a mutable reference to the configuration
138    fn config_mut(&mut self) -> &mut TemplateConfig;
139
140    /// Build the RunCommand for this template
141    fn build_command(&self) -> RunCommand {
142        let config = self.config();
143        let mut cmd = RunCommand::new(format!("{}:{}", config.image, config.tag))
144            .name(&config.name)
145            .detach();
146
147        // Add port mappings
148        for (host, container) in &config.ports {
149            cmd = cmd.port(*host, *container);
150        }
151
152        // Add environment variables
153        for (key, value) in &config.env {
154            cmd = cmd.env(key, value);
155        }
156
157        // Add volume mounts
158        for mount in &config.volumes {
159            if mount.read_only {
160                cmd = cmd.volume_ro(&mount.source, &mount.target);
161            } else {
162                cmd = cmd.volume(&mount.source, &mount.target);
163            }
164        }
165
166        // Add network
167        if let Some(network) = &config.network {
168            cmd = cmd.network(network);
169        }
170
171        // Add health check
172        if let Some(health) = &config.health_check {
173            cmd = cmd
174                .health_cmd(&health.test.join(" "))
175                .health_interval(&health.interval)
176                .health_timeout(&health.timeout)
177                .health_retries(health.retries)
178                .health_start_period(&health.start_period);
179        }
180
181        // Add resource limits
182        if let Some(memory) = &config.memory_limit {
183            cmd = cmd.memory(memory);
184        }
185
186        if let Some(cpu) = &config.cpu_limit {
187            cmd = cmd.cpus(cpu);
188        }
189
190        // Add platform if specified
191        if let Some(platform) = &config.platform {
192            cmd = cmd.platform(platform);
193        }
194
195        // Auto-remove
196        if config.auto_remove {
197            cmd = cmd.remove();
198        }
199
200        cmd
201    }
202
203    /// Start the container with this template
204    async fn start(&self) -> Result<String> {
205        let output = self.build_command().execute().await?;
206        Ok(output.0)
207    }
208
209    /// Start the container and wait for it to be ready
210    async fn start_and_wait(&self) -> Result<String> {
211        let container_id = self.start().await?;
212        self.wait_for_ready().await?;
213        Ok(container_id)
214    }
215
216    /// Stop the container
217    async fn stop(&self) -> Result<()> {
218        use crate::StopCommand;
219
220        StopCommand::new(self.config().name.as_str())
221            .execute()
222            .await?;
223
224        Ok(())
225    }
226
227    /// Remove the container
228    async fn remove(&self) -> Result<()> {
229        use crate::RmCommand;
230
231        RmCommand::new(self.config().name.as_str())
232            .force()
233            .volumes()
234            .execute()
235            .await?;
236
237        Ok(())
238    }
239
240    /// Check if the container is running
241    async fn is_running(&self) -> Result<bool> {
242        use crate::PsCommand;
243
244        let output = PsCommand::new()
245            .filter(format!("name={}", &self.config().name))
246            .quiet()
247            .execute()
248            .await?;
249
250        // In quiet mode, check if stdout contains any container IDs
251        Ok(!output.stdout.trim().is_empty())
252    }
253
254    /// Get container logs
255    async fn logs(&self, follow: bool, tail: Option<&str>) -> Result<crate::CommandOutput> {
256        use crate::LogsCommand;
257
258        let mut cmd = LogsCommand::new(&self.config().name);
259
260        if follow {
261            cmd = cmd.follow();
262        }
263
264        if let Some(lines) = tail {
265            cmd = cmd.tail(lines);
266        }
267
268        cmd.execute().await.map_err(Into::into)
269    }
270
271    /// Execute a command in the running container
272    async fn exec(&self, command: Vec<&str>) -> Result<crate::ExecOutput> {
273        use crate::ExecCommand;
274
275        let cmd_vec: Vec<String> = command.iter().map(|s| s.to_string()).collect();
276        let cmd = ExecCommand::new(&self.config().name, cmd_vec);
277
278        cmd.execute().await.map_err(Into::into)
279    }
280
281    /// Wait for the container to be ready
282    ///
283    /// This method will wait for the container to pass its health checks
284    /// or reach a ready state. The default implementation waits for the
285    /// container to be running and healthy (if health checks are configured).
286    ///
287    /// Templates can override this to provide custom readiness checks.
288    async fn wait_for_ready(&self) -> Result<()> {
289        use std::time::Duration;
290        use tokio::time::{sleep, timeout};
291
292        // Default timeout of 30 seconds
293        let wait_timeout = Duration::from_secs(30);
294        let check_interval = Duration::from_millis(500);
295
296        timeout(wait_timeout, async {
297            loop {
298                // First check if container is running
299                if !self.is_running().await? {
300                    return Err(TemplateError::NotRunning(self.config().name.clone()));
301                }
302
303                // If there's a health check configured, wait for it
304                if self.config().health_check.is_some() {
305                    use crate::InspectCommand;
306
307                    let inspect = InspectCommand::new(&self.config().name).execute().await?;
308
309                    // Check health status in the inspect output
310                    if let Ok(containers) =
311                        serde_json::from_str::<serde_json::Value>(&inspect.stdout)
312                    {
313                        if let Some(first) = containers.as_array().and_then(|arr| arr.first()) {
314                            if let Some(state) = first.get("State") {
315                                if let Some(health) = state.get("Health") {
316                                    if let Some(status) =
317                                        health.get("Status").and_then(|s| s.as_str())
318                                    {
319                                        if status == "healthy" {
320                                            return Ok(());
321                                        }
322                                    }
323                                } else if let Some(running) =
324                                    state.get("Running").and_then(|r| r.as_bool())
325                                {
326                                    // No health check configured, just check if running
327                                    if running {
328                                        return Ok(());
329                                    }
330                                }
331                            }
332                        }
333                    }
334                } else {
335                    // No health check, just ensure it's running
336                    return Ok(());
337                }
338
339                sleep(check_interval).await;
340            }
341        })
342        .await
343        .map_err(|_| {
344            TemplateError::InvalidConfig(format!(
345                "Container {} failed to become ready within timeout",
346                self.config().name
347            ))
348        })?
349    }
350}
351
352/// Builder for creating custom templates
353pub struct TemplateBuilder {
354    config: TemplateConfig,
355}
356
357impl TemplateBuilder {
358    /// Create a new template builder
359    pub fn new(name: impl Into<String>, image: impl Into<String>) -> Self {
360        Self {
361            config: TemplateConfig {
362                name: name.into(),
363                image: image.into(),
364                tag: "latest".to_string(),
365                ports: Vec::new(),
366                env: HashMap::new(),
367                volumes: Vec::new(),
368                network: None,
369                health_check: None,
370                auto_remove: false,
371                memory_limit: None,
372                cpu_limit: None,
373                platform: None,
374            },
375        }
376    }
377
378    /// Set the image tag
379    pub fn tag(mut self, tag: impl Into<String>) -> Self {
380        self.config.tag = tag.into();
381        self
382    }
383
384    /// Add a port mapping
385    pub fn port(mut self, host: u16, container: u16) -> Self {
386        self.config.ports.push((host, container));
387        self
388    }
389
390    /// Add an environment variable
391    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
392        self.config.env.insert(key.into(), value.into());
393        self
394    }
395
396    /// Add a volume mount
397    pub fn volume(mut self, source: impl Into<String>, target: impl Into<String>) -> Self {
398        self.config.volumes.push(VolumeMount {
399            source: source.into(),
400            target: target.into(),
401            read_only: false,
402        });
403        self
404    }
405
406    /// Add a read-only volume mount
407    pub fn volume_ro(mut self, source: impl Into<String>, target: impl Into<String>) -> Self {
408        self.config.volumes.push(VolumeMount {
409            source: source.into(),
410            target: target.into(),
411            read_only: true,
412        });
413        self
414    }
415
416    /// Set the network
417    pub fn network(mut self, network: impl Into<String>) -> Self {
418        self.config.network = Some(network.into());
419        self
420    }
421
422    /// Enable auto-remove
423    pub fn auto_remove(mut self) -> Self {
424        self.config.auto_remove = true;
425        self
426    }
427
428    /// Set memory limit
429    pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
430        self.config.memory_limit = Some(limit.into());
431        self
432    }
433
434    /// Set CPU limit
435    pub fn cpu_limit(mut self, limit: impl Into<String>) -> Self {
436        self.config.cpu_limit = Some(limit.into());
437        self
438    }
439
440    /// Build into a custom template
441    pub fn build(self) -> CustomTemplate {
442        CustomTemplate {
443            config: self.config,
444        }
445    }
446}
447
448/// A custom template created from `TemplateBuilder`
449pub struct CustomTemplate {
450    config: TemplateConfig,
451}
452
453#[async_trait]
454impl Template for CustomTemplate {
455    fn name(&self) -> &str {
456        &self.config.name
457    }
458
459    fn config(&self) -> &TemplateConfig {
460        &self.config
461    }
462
463    fn config_mut(&mut self) -> &mut TemplateConfig {
464        &mut self.config
465    }
466}
467
468// Compatibility re-exports for backward compatibility
469// These allow users to still import directly from template::
470#[cfg(feature = "template-redis")]
471pub use redis::RedisTemplate;
472
473#[cfg(feature = "template-redis-cluster")]
474pub use redis::{ClusterInfo, NodeInfo, NodeRole, RedisClusterConnection, RedisClusterTemplate};
475
476#[cfg(feature = "template-postgres")]
477pub use database::postgres::{PostgresConnectionString, PostgresTemplate};
478
479#[cfg(feature = "template-mysql")]
480pub use database::mysql::{MysqlConnectionString, MysqlTemplate};
481
482#[cfg(feature = "template-mongodb")]
483pub use database::mongodb::{MongodbConnectionString, MongodbTemplate};
484
485#[cfg(feature = "template-nginx")]
486pub use web::nginx::NginxTemplate;