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