docker_wrapper/
template.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 60 seconds (increased for slower systems/Windows)
293        let wait_timeout = Duration::from_secs(60);
294        let check_interval = Duration::from_millis(500);
295
296        timeout(wait_timeout, async {
297            loop {
298                // Check if container is running - keep retrying if not yet started
299                // Don't fail immediately as the container may still be starting up
300                if !self.is_running().await.unwrap_or(false) {
301                    sleep(check_interval).await;
302                    continue;
303                }
304
305                // If there's a health check configured, wait for it
306                if self.config().health_check.is_some() {
307                    use crate::InspectCommand;
308
309                    if let Ok(inspect) = InspectCommand::new(&self.config().name).execute().await {
310                        // Check health status in the inspect output
311                        if let Ok(containers) =
312                            serde_json::from_str::<serde_json::Value>(&inspect.stdout)
313                        {
314                            if let Some(first) = containers.as_array().and_then(|arr| arr.first()) {
315                                if let Some(state) = first.get("State") {
316                                    if let Some(health) = state.get("Health") {
317                                        if let Some(status) =
318                                            health.get("Status").and_then(|s| s.as_str())
319                                        {
320                                            if status == "healthy" {
321                                                return Ok(());
322                                            }
323                                        }
324                                    } else if let Some(running) =
325                                        state.get("Running").and_then(|r| r.as_bool())
326                                    {
327                                        // No health check configured, just check if running
328                                        if running {
329                                            return Ok(());
330                                        }
331                                    }
332                                }
333                            }
334                        }
335                    }
336                } else {
337                    // No health check, just ensure it's running
338                    return Ok(());
339                }
340
341                sleep(check_interval).await;
342            }
343        })
344        .await
345        .map_err(|_| {
346            TemplateError::InvalidConfig(format!(
347                "Container {} failed to become ready within timeout",
348                self.config().name
349            ))
350        })?
351    }
352}
353
354/// Builder for creating custom templates
355pub struct TemplateBuilder {
356    config: TemplateConfig,
357}
358
359impl TemplateBuilder {
360    /// Create a new template builder
361    pub fn new(name: impl Into<String>, image: impl Into<String>) -> Self {
362        Self {
363            config: TemplateConfig {
364                name: name.into(),
365                image: image.into(),
366                tag: "latest".to_string(),
367                ports: Vec::new(),
368                env: HashMap::new(),
369                volumes: Vec::new(),
370                network: None,
371                health_check: None,
372                auto_remove: false,
373                memory_limit: None,
374                cpu_limit: None,
375                platform: None,
376            },
377        }
378    }
379
380    /// Set the image tag
381    pub fn tag(mut self, tag: impl Into<String>) -> Self {
382        self.config.tag = tag.into();
383        self
384    }
385
386    /// Add a port mapping
387    pub fn port(mut self, host: u16, container: u16) -> Self {
388        self.config.ports.push((host, container));
389        self
390    }
391
392    /// Add an environment variable
393    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
394        self.config.env.insert(key.into(), value.into());
395        self
396    }
397
398    /// Add a volume mount
399    pub fn volume(mut self, source: impl Into<String>, target: impl Into<String>) -> Self {
400        self.config.volumes.push(VolumeMount {
401            source: source.into(),
402            target: target.into(),
403            read_only: false,
404        });
405        self
406    }
407
408    /// Add a read-only volume mount
409    pub fn volume_ro(mut self, source: impl Into<String>, target: impl Into<String>) -> Self {
410        self.config.volumes.push(VolumeMount {
411            source: source.into(),
412            target: target.into(),
413            read_only: true,
414        });
415        self
416    }
417
418    /// Set the network
419    pub fn network(mut self, network: impl Into<String>) -> Self {
420        self.config.network = Some(network.into());
421        self
422    }
423
424    /// Enable auto-remove
425    pub fn auto_remove(mut self) -> Self {
426        self.config.auto_remove = true;
427        self
428    }
429
430    /// Set memory limit
431    pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
432        self.config.memory_limit = Some(limit.into());
433        self
434    }
435
436    /// Set CPU limit
437    pub fn cpu_limit(mut self, limit: impl Into<String>) -> Self {
438        self.config.cpu_limit = Some(limit.into());
439        self
440    }
441
442    /// Build into a custom template
443    pub fn build(self) -> CustomTemplate {
444        CustomTemplate {
445            config: self.config,
446        }
447    }
448}
449
450/// A custom template created from `TemplateBuilder`
451pub struct CustomTemplate {
452    config: TemplateConfig,
453}
454
455#[async_trait]
456impl Template for CustomTemplate {
457    fn name(&self) -> &str {
458        &self.config.name
459    }
460
461    fn config(&self) -> &TemplateConfig {
462        &self.config
463    }
464
465    fn config_mut(&mut self) -> &mut TemplateConfig {
466        &mut self.config
467    }
468}
469
470// Compatibility re-exports for backward compatibility
471// These allow users to still import directly from template::
472#[cfg(feature = "template-redis")]
473pub use redis::RedisTemplate;
474
475#[cfg(feature = "template-redis-cluster")]
476pub use redis::{ClusterInfo, NodeInfo, NodeRole, RedisClusterConnection, RedisClusterTemplate};
477
478#[cfg(feature = "template-postgres")]
479pub use database::postgres::{PostgresConnectionString, PostgresTemplate};
480
481#[cfg(feature = "template-mysql")]
482pub use database::mysql::{MysqlConnectionString, MysqlTemplate};
483
484#[cfg(feature = "template-mongodb")]
485pub use database::mongodb::{MongodbConnectionString, MongodbTemplate};
486
487#[cfg(feature = "template-nginx")]
488pub use web::nginx::NginxTemplate;