Skip to main content

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