Skip to main content

armature_testing/
docker.rs

1//! Docker Test Containers
2//!
3//! Provides Docker-based testing with automatic container lifecycle management.
4
5use std::collections::HashMap;
6use std::process::{Command, Stdio};
7use thiserror::Error;
8
9/// Docker test container errors
10#[derive(Debug, Error)]
11pub enum DockerError {
12    #[error("Docker not available: {0}")]
13    NotAvailable(String),
14
15    #[error("Container start failed: {0}")]
16    StartFailed(String),
17
18    #[error("Container stop failed: {0}")]
19    StopFailed(String),
20
21    #[error("Image pull failed: {0}")]
22    PullFailed(String),
23
24    #[error("Container not found: {0}")]
25    NotFound(String),
26}
27
28/// Docker container configuration
29#[derive(Debug, Clone)]
30pub struct ContainerConfig {
31    /// Docker image
32    pub image: String,
33
34    /// Image tag
35    pub tag: String,
36
37    /// Container name
38    pub name: Option<String>,
39
40    /// Environment variables
41    pub env: HashMap<String, String>,
42
43    /// Port mappings (host_port -> container_port)
44    pub ports: HashMap<u16, u16>,
45
46    /// Wait for container to be ready
47    pub wait_timeout_secs: u64,
48}
49
50impl ContainerConfig {
51    /// Create new container config
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// use armature_testing::docker::ContainerConfig;
57    ///
58    /// let config = ContainerConfig::new("postgres", "15");
59    /// ```
60    pub fn new(image: impl Into<String>, tag: impl Into<String>) -> Self {
61        Self {
62            image: image.into(),
63            tag: tag.into(),
64            name: None,
65            env: HashMap::new(),
66            ports: HashMap::new(),
67            wait_timeout_secs: 30,
68        }
69    }
70
71    /// Set container name
72    pub fn with_name(mut self, name: impl Into<String>) -> Self {
73        self.name = Some(name.into());
74        self
75    }
76
77    /// Add environment variable
78    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
79        self.env.insert(key.into(), value.into());
80        self
81    }
82
83    /// Add port mapping
84    pub fn with_port(mut self, host_port: u16, container_port: u16) -> Self {
85        self.ports.insert(host_port, container_port);
86        self
87    }
88
89    /// Set wait timeout
90    pub fn with_wait_timeout(mut self, seconds: u64) -> Self {
91        self.wait_timeout_secs = seconds;
92        self
93    }
94
95    /// Get full image name
96    pub fn image_name(&self) -> String {
97        format!("{}:{}", self.image, self.tag)
98    }
99}
100
101/// Docker test container
102pub struct DockerContainer {
103    config: ContainerConfig,
104    container_id: Option<String>,
105}
106
107impl DockerContainer {
108    /// Create new Docker container
109    ///
110    /// # Examples
111    ///
112    /// ```
113    /// use armature_testing::docker::*;
114    ///
115    /// let config = ContainerConfig::new("postgres", "15")
116    ///     .with_env("POSTGRES_PASSWORD", "test")
117    ///     .with_port(5432, 5432);
118    ///
119    /// let container = DockerContainer::new(config);
120    /// ```
121    pub fn new(config: ContainerConfig) -> Self {
122        Self {
123            config,
124            container_id: None,
125        }
126    }
127
128    /// Check if Docker is available
129    pub fn is_docker_available() -> bool {
130        Command::new("docker")
131            .arg("--version")
132            .stdout(Stdio::null())
133            .stderr(Stdio::null())
134            .status()
135            .map(|s| s.success())
136            .unwrap_or(false)
137    }
138
139    /// Start container
140    pub async fn start(&mut self) -> Result<(), DockerError> {
141        if !Self::is_docker_available() {
142            return Err(DockerError::NotAvailable(
143                "Docker not found. Please install Docker.".to_string(),
144            ));
145        }
146
147        // Pull image if needed
148        self.pull_image()?;
149
150        // Build docker run command
151        let mut cmd = Command::new("docker");
152        cmd.arg("run")
153            .arg("-d") // Detached
154            .arg("--rm"); // Auto-remove on stop
155
156        // Container name
157        if let Some(ref name) = self.config.name {
158            cmd.arg("--name").arg(name);
159        }
160
161        // Environment variables
162        for (key, value) in &self.config.env {
163            cmd.arg("-e").arg(format!("{}={}", key, value));
164        }
165
166        // Port mappings
167        for (host_port, container_port) in &self.config.ports {
168            cmd.arg("-p")
169                .arg(format!("{}:{}", host_port, container_port));
170        }
171
172        // Image
173        cmd.arg(self.config.image_name());
174
175        // Execute
176        let output = cmd
177            .output()
178            .map_err(|e| DockerError::StartFailed(e.to_string()))?;
179
180        if !output.status.success() {
181            let error = String::from_utf8_lossy(&output.stderr);
182            return Err(DockerError::StartFailed(error.to_string()));
183        }
184
185        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
186        self.container_id = Some(container_id);
187
188        // Wait for the container to report itself running, polling up to
189        // the configured `wait_timeout_secs` instead of a fixed sleep.
190        let ready = Self::poll_until_ready(
191            std::time::Duration::from_secs(self.config.wait_timeout_secs.max(1)),
192            std::time::Duration::from_millis(200),
193            || self.is_running(),
194        )
195        .await;
196
197        if !ready {
198            return Err(DockerError::StartFailed(format!(
199                "container did not report running within wait_timeout_secs ({}s)",
200                self.config.wait_timeout_secs
201            )));
202        }
203
204        Ok(())
205    }
206
207    /// Poll `ready` at `poll_interval` until it returns `true` or `timeout`
208    /// elapses. Returns `true` as soon as `ready` succeeds, `false` if the
209    /// deadline passes first. Factored out of `start` so the readiness-wait
210    /// behavior (honoring a configurable timeout instead of a fixed sleep)
211    /// is testable without a real Docker daemon.
212    async fn poll_until_ready<F>(
213        timeout: std::time::Duration,
214        poll_interval: std::time::Duration,
215        mut ready: F,
216    ) -> bool
217    where
218        F: FnMut() -> bool,
219    {
220        let deadline = std::time::Instant::now() + timeout;
221        loop {
222            if ready() {
223                return true;
224            }
225            if std::time::Instant::now() >= deadline {
226                return false;
227            }
228            tokio::time::sleep(poll_interval).await;
229        }
230    }
231
232    /// Stop container
233    pub async fn stop(&mut self) -> Result<(), DockerError> {
234        if let Some(ref container_id) = self.container_id {
235            let output = Command::new("docker")
236                .arg("stop")
237                .arg(container_id)
238                .output()
239                .map_err(|e| DockerError::StopFailed(e.to_string()))?;
240
241            if !output.status.success() {
242                let error = String::from_utf8_lossy(&output.stderr);
243                return Err(DockerError::StopFailed(error.to_string()));
244            }
245
246            self.container_id = None;
247        }
248
249        Ok(())
250    }
251
252    /// Pull Docker image
253    fn pull_image(&self) -> Result<(), DockerError> {
254        let output = Command::new("docker")
255            .arg("pull")
256            .arg(self.config.image_name())
257            .output()
258            .map_err(|e| DockerError::PullFailed(e.to_string()))?;
259
260        if !output.status.success() {
261            let error = String::from_utf8_lossy(&output.stderr);
262            return Err(DockerError::PullFailed(error.to_string()));
263        }
264
265        Ok(())
266    }
267
268    /// Get container ID
269    pub fn container_id(&self) -> Option<&str> {
270        self.container_id.as_deref()
271    }
272
273    /// Check if container is running
274    pub fn is_running(&self) -> bool {
275        if let Some(ref container_id) = self.container_id {
276            Command::new("docker")
277                .arg("inspect")
278                .arg("-f")
279                .arg("{{.State.Running}}")
280                .arg(container_id)
281                .output()
282                .ok()
283                .and_then(|o| String::from_utf8(o.stdout).ok())
284                .map(|s| s.trim() == "true")
285                .unwrap_or(false)
286        } else {
287            false
288        }
289    }
290}
291
292impl Drop for DockerContainer {
293    fn drop(&mut self) {
294        // Stop the container synchronously via a blocking `docker stop`
295        // command, never by spinning up a nested Tokio runtime: the
296        // documented usage is dropping a `DockerContainer` inside an async
297        // test (`#[tokio::test]`), and `Runtime::new().unwrap().block_on(..)`
298        // there panics with "Cannot start a runtime from within a runtime"
299        // instead of cleaning up.
300        if let Some(container_id) = self.container_id.take() {
301            let _ = Command::new("docker")
302                .arg("stop")
303                .arg(&container_id)
304                .stdout(Stdio::null())
305                .stderr(Stdio::null())
306                .status();
307        }
308    }
309}
310
311/// Postgres test container helper
312pub struct PostgresContainer;
313
314impl PostgresContainer {
315    /// Create Postgres test container
316    ///
317    /// # Examples
318    ///
319    /// ```
320    /// use armature_testing::docker::PostgresContainer;
321    ///
322    /// let config = PostgresContainer::config("testdb", "testuser", "testpass");
323    /// ```
324    pub fn config(database: &str, username: &str, password: &str) -> ContainerConfig {
325        ContainerConfig::new("postgres", "15")
326            .with_name(format!("armature-test-postgres-{}", uuid::Uuid::new_v4()))
327            .with_env("POSTGRES_DB", database)
328            .with_env("POSTGRES_USER", username)
329            .with_env("POSTGRES_PASSWORD", password)
330            .with_port(5432, 5432)
331            .with_wait_timeout(10)
332    }
333}
334
335/// Redis test container helper
336pub struct RedisContainer;
337
338impl RedisContainer {
339    /// Create Redis test container
340    ///
341    /// # Examples
342    ///
343    /// ```
344    /// use armature_testing::docker::RedisContainer;
345    ///
346    /// let config = RedisContainer::config();
347    /// ```
348    pub fn config() -> ContainerConfig {
349        ContainerConfig::new("redis", "7")
350            .with_name(format!("armature-test-redis-{}", uuid::Uuid::new_v4()))
351            .with_port(6379, 6379)
352            .with_wait_timeout(5)
353    }
354}
355
356/// MongoDB test container helper
357pub struct MongoContainer;
358
359impl MongoContainer {
360    /// Create MongoDB test container
361    pub fn config(database: &str) -> ContainerConfig {
362        ContainerConfig::new("mongo", "7")
363            .with_name(format!("armature-test-mongo-{}", uuid::Uuid::new_v4()))
364            .with_env("MONGO_INITDB_DATABASE", database)
365            .with_port(27017, 27017)
366            .with_wait_timeout(10)
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_container_config() {
376        let config = ContainerConfig::new("postgres", "15")
377            .with_name("test-db")
378            .with_env("POSTGRES_PASSWORD", "test")
379            .with_port(5432, 5432);
380
381        assert_eq!(config.image, "postgres");
382        assert_eq!(config.tag, "15");
383        assert_eq!(config.name, Some("test-db".to_string()));
384        assert_eq!(
385            config.env.get("POSTGRES_PASSWORD"),
386            Some(&"test".to_string())
387        );
388        assert_eq!(config.ports.get(&5432), Some(&5432));
389    }
390
391    #[test]
392    fn test_postgres_container_config() {
393        let config = PostgresContainer::config("testdb", "user", "pass");
394        assert_eq!(config.image, "postgres");
395        assert_eq!(config.env.get("POSTGRES_DB"), Some(&"testdb".to_string()));
396    }
397
398    #[test]
399    fn test_redis_container_config() {
400        let config = RedisContainer::config();
401        assert_eq!(config.image, "redis");
402        assert_eq!(config.tag, "7");
403    }
404
405    #[test]
406    fn test_docker_available() {
407        // This will vary by system
408        let _ = DockerContainer::is_docker_available();
409    }
410
411    /// Docker-free: proves the readiness wait honors a configurable timeout
412    /// (rather than a fixed 2s sleep) by never reporting ready and checking
413    /// that `poll_until_ready` actually waited out the deadline.
414    #[tokio::test]
415    async fn poll_until_ready_honors_timeout_when_never_ready() {
416        let start = std::time::Instant::now();
417        let ready = DockerContainer::poll_until_ready(
418            std::time::Duration::from_millis(150),
419            std::time::Duration::from_millis(20),
420            || false,
421        )
422        .await;
423
424        assert!(!ready);
425        assert!(start.elapsed() >= std::time::Duration::from_millis(150));
426    }
427
428    /// Docker-free: proves readiness is detected as soon as it flips true,
429    /// rather than always sleeping the full window.
430    #[tokio::test]
431    async fn poll_until_ready_returns_as_soon_as_ready() {
432        let mut calls = 0u32;
433        let start = std::time::Instant::now();
434
435        let ready = DockerContainer::poll_until_ready(
436            std::time::Duration::from_secs(5),
437            std::time::Duration::from_millis(10),
438            || {
439                calls += 1;
440                calls >= 3
441            },
442        )
443        .await;
444
445        assert!(ready);
446        assert!(calls >= 3);
447        // Should return well before the 5s timeout.
448        assert!(start.elapsed() < std::time::Duration::from_secs(1));
449    }
450
451    /// Docker-free: reproduces the exact scenario the old `Drop` impl
452    /// broke — dropping a `DockerContainer` with a live `container_id`
453    /// while already inside a Tokio runtime (`#[tokio::test]`). The old
454    /// `tokio::runtime::Runtime::new().unwrap().block_on(self.stop())`
455    /// panicked with "Cannot start a runtime from within a runtime" here,
456    /// regardless of whether a real Docker daemon or container is present
457    /// (the panic fires before any `docker` command even runs). No Docker
458    /// installation is required: the synchronous `docker stop` the new Drop
459    /// issues is allowed to fail (ignored) for a nonexistent container id.
460    #[tokio::test]
461    async fn drop_inside_tokio_runtime_does_not_panic() {
462        let container = DockerContainer {
463            config: ContainerConfig::new("redis", "7"),
464            container_id: Some("armature-testing-nonexistent-container".to_string()),
465        };
466
467        // Must not panic. If it does, the test process aborts here.
468        drop(container);
469    }
470
471    /// Docker-gated: exercises the real `start()` → readiness poll →
472    /// (implicit) `Drop` → stop lifecycle end-to-end against an actual
473    /// Docker daemon, inside a `#[tokio::test]` runtime (the documented
474    /// usage). Self-skips when Docker is unavailable so the default
475    /// `cargo test` stays Docker-free; set `ARMATURE_REQUIRE_DOCKER=1` to
476    /// turn the skip into a hard failure (e.g. in CI, where Docker should
477    /// be present).
478    #[tokio::test]
479    async fn start_and_drop_a_real_container_when_docker_is_available() {
480        if !DockerContainer::is_docker_available() {
481            if std::env::var("ARMATURE_REQUIRE_DOCKER").as_deref() == Ok("1") {
482                panic!(
483                    "ARMATURE_REQUIRE_DOCKER=1 is set but no Docker daemon is reachable: \
484                     this container-gated test must run, not skip."
485                );
486            }
487            eprintln!(
488                "skipping: Docker not available (set ARMATURE_REQUIRE_DOCKER=1 to make this a failure)"
489            );
490            return;
491        }
492
493        let config = RedisContainer::config().with_wait_timeout(15);
494        let mut container = DockerContainer::new(config);
495
496        container
497            .start()
498            .await
499            .expect("container should start and become ready within wait_timeout_secs");
500        assert!(container.is_running());
501
502        // Drop runs here, inside this test's Tokio runtime — must not
503        // panic, and must actually stop the container synchronously.
504        let container_id = container.container_id().unwrap().to_string();
505        drop(container);
506
507        let still_running = Command::new("docker")
508            .arg("inspect")
509            .arg("-f")
510            .arg("{{.State.Running}}")
511            .arg(&container_id)
512            .output()
513            .ok()
514            .and_then(|o| String::from_utf8(o.stdout).ok())
515            .map(|s| s.trim() == "true")
516            .unwrap_or(false);
517        assert!(!still_running, "Drop should have stopped the container");
518    }
519}