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