docker_wrapper/testing.rs
1//! # Testing Utilities
2//!
3//! RAII-style container lifecycle management for integration tests.
4//!
5//! This module provides [`ContainerGuard`] and [`ContainerGuardSet`] for automatic
6//! container lifecycle management. Containers are automatically stopped and removed
7//! when guards go out of scope, ensuring clean test environments.
8//!
9//! ## Why Use This?
10//!
11//! - **Automatic cleanup**: No more forgotten containers cluttering your Docker
12//! - **Panic-safe**: Containers are cleaned up even if your test panics
13//! - **Debug-friendly**: Keep containers alive on failure for inspection
14//! - **Network support**: Automatic network creation for multi-container tests
15//! - **Ready checks**: Wait for services to be ready before running tests
16//!
17//! ## Quick Start
18//!
19//! ```rust,no_run
20//! use docker_wrapper::testing::ContainerGuard;
21//! use docker_wrapper::RedisTemplate;
22//!
23//! #[tokio::test]
24//! async fn test_with_redis() -> Result<(), Box<dyn std::error::Error>> {
25//! // Container starts and waits for Redis to be ready
26//! let guard = ContainerGuard::new(RedisTemplate::new("test-redis"))
27//! .wait_for_ready(true)
28//! .start()
29//! .await?;
30//!
31//! // Get connection string directly from guard
32//! let url = guard.connection_string();
33//! // Use Redis at: redis://localhost:6379
34//!
35//! Ok(())
36//! // Container automatically stopped and removed here
37//! }
38//! ```
39//!
40//! ## Configuration Options
41//!
42//! ### Lifecycle Control
43//!
44//! ```rust,no_run
45//! # use docker_wrapper::testing::ContainerGuard;
46//! # use docker_wrapper::RedisTemplate;
47//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
48//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
49//! .stop_on_drop(true) // Stop container on drop (default: true)
50//! .remove_on_drop(true) // Remove container on drop (default: true)
51//! .start()
52//! .await?;
53//! # Ok(())
54//! # }
55//! ```
56//!
57//! ### Debugging Failed Tests
58//!
59//! Keep containers running when tests fail for debugging:
60//!
61//! ```rust,no_run
62//! # use docker_wrapper::testing::ContainerGuard;
63//! # use docker_wrapper::RedisTemplate;
64//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
65//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
66//! .keep_on_panic(true) // Keep container if test panics
67//! .capture_logs(true) // Print container logs on panic
68//! .start()
69//! .await?;
70//! # Ok(())
71//! # }
72//! ```
73//!
74//! ### Ready Checks
75//!
76//! Wait for the service to be ready before proceeding:
77//!
78//! ```rust,no_run
79//! # use docker_wrapper::testing::ContainerGuard;
80//! # use docker_wrapper::RedisTemplate;
81//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
82//! // Automatic wait during start
83//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
84//! .wait_for_ready(true)
85//! .start()
86//! .await?;
87//! // Redis is guaranteed ready here
88//!
89//! // Or wait manually later
90//! let guard2 = ContainerGuard::new(RedisTemplate::new("redis2"))
91//! .start()
92//! .await?;
93//! guard2.wait_for_ready().await?;
94//! # Ok(())
95//! # }
96//! ```
97//!
98//! ### Container Reuse
99//!
100//! Speed up local development by reusing running containers:
101//!
102//! ```rust,no_run
103//! # use docker_wrapper::testing::ContainerGuard;
104//! # use docker_wrapper::RedisTemplate;
105//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
106//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
107//! .reuse_if_running(true) // Reuse existing container if found
108//! .remove_on_drop(false) // Keep it for next test run
109//! .stop_on_drop(false)
110//! .start()
111//! .await?;
112//!
113//! if guard.was_reused() {
114//! println!("Reused existing container");
115//! }
116//! # Ok(())
117//! # }
118//! ```
119//!
120//! ### Network Support
121//!
122//! Attach containers to custom networks:
123//!
124//! ```rust,no_run
125//! # use docker_wrapper::testing::ContainerGuard;
126//! # use docker_wrapper::RedisTemplate;
127//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
128//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
129//! .with_network("my-test-network") // Create and attach to network
130//! .remove_network_on_drop(true) // Clean up network after test
131//! .start()
132//! .await?;
133//! # Ok(())
134//! # }
135//! ```
136//!
137//! ### Fast Cleanup
138//!
139//! Use a short stop timeout for faster test cleanup:
140//!
141//! ```rust,no_run
142//! # use docker_wrapper::testing::ContainerGuard;
143//! # use docker_wrapper::RedisTemplate;
144//! # use std::time::Duration;
145//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
146//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
147//! .stop_timeout(Duration::from_secs(1)) // 1 second graceful shutdown
148//! .start()
149//! .await?;
150//!
151//! // Or immediate SIGKILL
152//! let guard2 = ContainerGuard::new(RedisTemplate::new("redis2"))
153//! .stop_timeout(Duration::ZERO)
154//! .start()
155//! .await?;
156//! # Ok(())
157//! # }
158//! ```
159//!
160//! ## Multi-Container Tests
161//!
162//! Use [`ContainerGuardSet`] for tests requiring multiple services:
163//!
164//! ```rust,no_run
165//! use docker_wrapper::testing::ContainerGuardSet;
166//! use docker_wrapper::RedisTemplate;
167//!
168//! #[tokio::test]
169//! async fn test_multi_container() -> Result<(), Box<dyn std::error::Error>> {
170//! let guards = ContainerGuardSet::new()
171//! .with_network("test-network") // Shared network for all containers
172//! .add(RedisTemplate::new("redis-primary").port(6379))
173//! .add(RedisTemplate::new("redis-replica").port(6380))
174//! .keep_on_panic(true)
175//! .start_all()
176//! .await?;
177//!
178//! assert!(guards.contains("redis-primary"));
179//! assert!(guards.contains("redis-replica"));
180//! assert_eq!(guards.len(), 2);
181//!
182//! // All containers cleaned up together
183//! Ok(())
184//! }
185//! ```
186//!
187//! ## Accessing Container Information
188//!
189//! ```rust,no_run
190//! # use docker_wrapper::testing::ContainerGuard;
191//! # use docker_wrapper::RedisTemplate;
192//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
193//! let guard = ContainerGuard::new(RedisTemplate::new("redis").port(6379))
194//! .start()
195//! .await?;
196//!
197//! // Connection string (for templates that support it)
198//! let conn = guard.connection_string();
199//!
200//! // Access underlying template
201//! let template = guard.template();
202//!
203//! // Get container ID
204//! if let Some(id) = guard.container_id() {
205//! println!("Container ID: {}", id);
206//! }
207//!
208//! // Query host port for a container port
209//! let host_port = guard.host_port(6379).await?;
210//!
211//! // Get container logs
212//! let logs = guard.logs().await?;
213//!
214//! // Check if running
215//! let running = guard.is_running().await?;
216//! # Ok(())
217//! # }
218//! ```
219//!
220//! ## Common Patterns
221//!
222//! ### Test Fixtures
223//!
224//! Create reusable test fixtures:
225//!
226//! ```rust,no_run
227//! use docker_wrapper::testing::ContainerGuard;
228//! use docker_wrapper::RedisTemplate;
229//! use docker_wrapper::template::TemplateError;
230//!
231//! async fn redis_fixture(name: &str) -> Result<ContainerGuard<RedisTemplate>, TemplateError> {
232//! ContainerGuard::new(RedisTemplate::new(name))
233//! .wait_for_ready(true)
234//! .keep_on_panic(true)
235//! .capture_logs(true)
236//! .start()
237//! .await
238//! }
239//!
240//! #[tokio::test]
241//! async fn test_using_fixture() -> Result<(), Box<dyn std::error::Error>> {
242//! let redis = redis_fixture("test-redis").await?;
243//! // Use redis...
244//! Ok(())
245//! }
246//! ```
247//!
248//! ### Unique Container Names
249//!
250//! Use UUIDs to avoid name conflicts in parallel tests:
251//!
252//! ```rust,no_run
253//! # use docker_wrapper::testing::ContainerGuard;
254//! # use docker_wrapper::RedisTemplate;
255//! fn unique_name(prefix: &str) -> String {
256//! format!("{}-{}", prefix, uuid::Uuid::new_v4())
257//! }
258//!
259//! #[tokio::test]
260//! async fn test_parallel_safe() -> Result<(), Box<dyn std::error::Error>> {
261//! let name = unique_name("redis");
262//! let guard = ContainerGuard::new(RedisTemplate::new(&name))
263//! .start()
264//! .await?;
265//! Ok(())
266//! }
267//! ```
268//!
269//! ### Manual Cleanup
270//!
271//! Trigger cleanup explicitly when needed:
272//!
273//! ```rust,no_run
274//! # use docker_wrapper::testing::ContainerGuard;
275//! # use docker_wrapper::RedisTemplate;
276//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
277//! let guard = ContainerGuard::new(RedisTemplate::new("redis"))
278//! .start()
279//! .await?;
280//!
281//! // Do some work...
282//!
283//! // Explicitly cleanup (idempotent - safe to call multiple times)
284//! guard.cleanup().await?;
285//!
286//! // Drop will not try to clean up again
287//! # Ok(())
288//! # }
289//! ```
290//!
291//! ## Feature Flag
292//!
293//! This module requires the `testing` feature:
294//!
295//! ```toml
296//! [dev-dependencies]
297//! docker-wrapper = { version = "0.10", features = ["testing", "template-redis"] }
298//! ```
299
300use crate::command::DockerCommand;
301use crate::template::{HasConnectionString, Template, TemplateError};
302use crate::{
303 KillCommand, LogsCommand, NetworkConnectCommand, NetworkCreateCommand,
304 NetworkDisconnectCommand, NetworkRmCommand, PauseCommand, PortCommand, RestartCommand,
305 RmCommand, StopCommand, UnpauseCommand,
306};
307use std::collections::HashMap;
308use std::sync::atomic::{AtomicBool, Ordering};
309use std::sync::Arc;
310use std::time::Duration;
311
312/// Options for controlling container lifecycle behavior.
313#[derive(Debug, Clone)]
314#[allow(clippy::struct_excessive_bools)]
315pub struct GuardOptions {
316 /// Remove container on drop (default: true)
317 pub remove_on_drop: bool,
318 /// Stop container on drop (default: true)
319 pub stop_on_drop: bool,
320 /// Keep container running if test panics (default: false)
321 pub keep_on_panic: bool,
322 /// Capture container logs and print on panic (default: false)
323 pub capture_logs: bool,
324 /// Reuse existing container if already running (default: false)
325 pub reuse_if_running: bool,
326 /// Automatically wait for container to be ready after start (default: false)
327 pub wait_for_ready: bool,
328 /// Network to attach the container to (default: None)
329 pub network: Option<String>,
330 /// Create the network if it doesn't exist (default: true when network is set)
331 pub create_network: bool,
332 /// Remove the network on drop (default: false)
333 pub remove_network_on_drop: bool,
334 /// Timeout for stop operations during cleanup (default: None, uses Docker default)
335 pub stop_timeout: Option<Duration>,
336}
337
338impl Default for GuardOptions {
339 fn default() -> Self {
340 Self {
341 remove_on_drop: true,
342 stop_on_drop: true,
343 keep_on_panic: false,
344 capture_logs: false,
345 reuse_if_running: false,
346 wait_for_ready: false,
347 network: None,
348 create_network: true,
349 remove_network_on_drop: false,
350 stop_timeout: None,
351 }
352 }
353}
354
355/// Builder for creating a [`ContainerGuard`] with custom options.
356pub struct ContainerGuardBuilder<T: Template> {
357 template: T,
358 options: GuardOptions,
359}
360
361impl<T: Template> ContainerGuardBuilder<T> {
362 /// Create a new builder with the given template.
363 #[must_use]
364 pub fn new(template: T) -> Self {
365 Self {
366 template,
367 options: GuardOptions::default(),
368 }
369 }
370
371 /// Set whether to remove the container on drop (default: true).
372 #[must_use]
373 pub fn remove_on_drop(mut self, remove: bool) -> Self {
374 self.options.remove_on_drop = remove;
375 self
376 }
377
378 /// Set whether to stop the container on drop (default: true).
379 #[must_use]
380 pub fn stop_on_drop(mut self, stop: bool) -> Self {
381 self.options.stop_on_drop = stop;
382 self
383 }
384
385 /// Set whether to keep the container running if the test panics (default: false).
386 ///
387 /// This is useful for debugging failed tests - you can inspect the container
388 /// state after the test fails.
389 #[must_use]
390 pub fn keep_on_panic(mut self, keep: bool) -> Self {
391 self.options.keep_on_panic = keep;
392 self
393 }
394
395 /// Set whether to capture container logs and print them on panic (default: false).
396 ///
397 /// When enabled, container logs are buffered and printed to stderr if the
398 /// test panics, making it easier to debug failures.
399 #[must_use]
400 pub fn capture_logs(mut self, capture: bool) -> Self {
401 self.options.capture_logs = capture;
402 self
403 }
404
405 /// Set whether to reuse an existing container if already running (default: false).
406 ///
407 /// This is useful for faster local development iteration - containers can
408 /// be kept running between test runs.
409 #[must_use]
410 pub fn reuse_if_running(mut self, reuse: bool) -> Self {
411 self.options.reuse_if_running = reuse;
412 self
413 }
414
415 /// Set whether to automatically wait for the container to be ready after starting (default: false).
416 ///
417 /// When enabled, `start()` will not return until the container passes its
418 /// readiness check. This is useful for tests that need to immediately connect
419 /// to the service.
420 ///
421 /// # Example
422 ///
423 /// ```rust,no_run
424 /// # use docker_wrapper::testing::ContainerGuard;
425 /// # use docker_wrapper::RedisTemplate;
426 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
427 /// let guard = ContainerGuard::new(RedisTemplate::new("test"))
428 /// .wait_for_ready(true)
429 /// .start()
430 /// .await?;
431 /// // Container is guaranteed ready at this point
432 /// # Ok(())
433 /// # }
434 /// ```
435 #[must_use]
436 pub fn wait_for_ready(mut self, wait: bool) -> Self {
437 self.options.wait_for_ready = wait;
438 self
439 }
440
441 /// Attach the container to a Docker network.
442 ///
443 /// By default, the network will be created if it doesn't exist. Use
444 /// `create_network(false)` to disable automatic network creation.
445 ///
446 /// # Example
447 ///
448 /// ```rust,no_run
449 /// # use docker_wrapper::testing::ContainerGuard;
450 /// # use docker_wrapper::RedisTemplate;
451 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
452 /// let guard = ContainerGuard::new(RedisTemplate::new("redis"))
453 /// .with_network("test-network")
454 /// .start()
455 /// .await?;
456 /// // Container is attached to "test-network"
457 /// # Ok(())
458 /// # }
459 /// ```
460 #[must_use]
461 pub fn with_network(mut self, network: impl Into<String>) -> Self {
462 self.options.network = Some(network.into());
463 self
464 }
465
466 /// Set whether to create the network if it doesn't exist (default: true).
467 ///
468 /// Only applies when a network is specified via `with_network()`.
469 #[must_use]
470 pub fn create_network(mut self, create: bool) -> Self {
471 self.options.create_network = create;
472 self
473 }
474
475 /// Set whether to remove the network on drop (default: false).
476 ///
477 /// This is useful for cleaning up test-specific networks. Only applies
478 /// when a network is specified via `with_network()`.
479 ///
480 /// Note: The network removal will fail silently if other containers are
481 /// still using it.
482 #[must_use]
483 pub fn remove_network_on_drop(mut self, remove: bool) -> Self {
484 self.options.remove_network_on_drop = remove;
485 self
486 }
487
488 /// Set the timeout for stop operations during cleanup (default: Docker default).
489 ///
490 /// This controls how long Docker waits for the container to stop gracefully
491 /// before sending SIGKILL.
492 ///
493 /// # Example
494 ///
495 /// ```rust,no_run
496 /// # use docker_wrapper::testing::ContainerGuard;
497 /// # use docker_wrapper::RedisTemplate;
498 /// # use std::time::Duration;
499 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
500 /// // Fast cleanup with 1 second timeout
501 /// let guard = ContainerGuard::new(RedisTemplate::new("redis"))
502 /// .stop_timeout(Duration::from_secs(1))
503 /// .start()
504 /// .await?;
505 ///
506 /// // Immediate SIGKILL with zero timeout
507 /// let guard = ContainerGuard::new(RedisTemplate::new("redis2"))
508 /// .stop_timeout(Duration::ZERO)
509 /// .start()
510 /// .await?;
511 /// # Ok(())
512 /// # }
513 /// ```
514 #[must_use]
515 pub fn stop_timeout(mut self, timeout: Duration) -> Self {
516 self.options.stop_timeout = Some(timeout);
517 self
518 }
519
520 /// Start the container and return a guard that manages its lifecycle.
521 ///
522 /// If `reuse_if_running` is enabled and a container is already running,
523 /// it will be reused instead of starting a new one.
524 ///
525 /// If `wait_for_ready` is enabled, this method will block until the
526 /// container passes its readiness check.
527 ///
528 /// If a network is specified via `with_network()`, the container will be
529 /// attached to that network. The network will be created if it doesn't
530 /// exist (unless `create_network(false)` was called).
531 ///
532 /// # Errors
533 ///
534 /// Returns an error if the container fails to start or the readiness check times out.
535 pub async fn start(mut self) -> Result<ContainerGuard<T>, TemplateError> {
536 let wait_for_ready = self.options.wait_for_ready;
537 let mut network_created = false;
538
539 // Create network if specified and create_network is enabled
540 if let Some(ref network) = self.options.network {
541 if self.options.create_network {
542 // Try to create the network (ignore errors if it already exists)
543 let result = NetworkCreateCommand::new(network)
544 .driver("bridge")
545 .execute()
546 .await;
547
548 // Track if we successfully created it (for cleanup purposes)
549 network_created = result.is_ok();
550 }
551
552 // Set the network on the template
553 self.template.config_mut().network = Some(network.clone());
554 }
555
556 // Check if we should reuse an existing container
557 if self.options.reuse_if_running {
558 if let Ok(true) = self.template.is_running().await {
559 let guard = ContainerGuard {
560 template: self.template,
561 container_id: None, // We don't have the ID for reused containers
562 options: self.options,
563 was_reused: true,
564 network_created,
565 cleaned_up: Arc::new(AtomicBool::new(false)),
566 };
567
568 // Wait for ready if configured (even for reused containers)
569 if wait_for_ready {
570 guard.wait_for_ready().await?;
571 }
572
573 return Ok(guard);
574 }
575 }
576
577 // Start the container
578 let container_id = self.template.start_and_wait().await?;
579
580 let guard = ContainerGuard {
581 template: self.template,
582 container_id: Some(container_id),
583 options: self.options,
584 was_reused: false,
585 network_created,
586 cleaned_up: Arc::new(AtomicBool::new(false)),
587 };
588
589 // Wait for ready if configured
590 if wait_for_ready {
591 guard.wait_for_ready().await?;
592 }
593
594 Ok(guard)
595 }
596}
597
598/// RAII guard for automatic container lifecycle management.
599///
600/// When this guard is dropped, the container is automatically stopped and
601/// removed (unless configured otherwise via [`ContainerGuardBuilder`]).
602///
603/// # Example
604///
605/// ```rust,no_run
606/// use docker_wrapper::testing::ContainerGuard;
607/// use docker_wrapper::RedisTemplate;
608///
609/// #[tokio::test]
610/// async fn test_example() -> Result<(), Box<dyn std::error::Error>> {
611/// let guard = ContainerGuard::new(RedisTemplate::new("test"))
612/// .keep_on_panic(true) // Keep container for debugging if test fails
613/// .capture_logs(true) // Print logs on failure
614/// .start()
615/// .await?;
616///
617/// // Container is automatically cleaned up when guard goes out of scope
618/// Ok(())
619/// }
620/// ```
621pub struct ContainerGuard<T: Template> {
622 template: T,
623 container_id: Option<String>,
624 options: GuardOptions,
625 was_reused: bool,
626 network_created: bool,
627 cleaned_up: Arc<AtomicBool>,
628}
629
630impl<T: Template> ContainerGuard<T> {
631 /// Create a new builder for a container guard.
632 ///
633 /// Note: This returns a builder, not a `ContainerGuard`. Call `.start().await`
634 /// on the builder to create the guard.
635 #[allow(clippy::new_ret_no_self)]
636 pub fn new(template: T) -> ContainerGuardBuilder<T> {
637 ContainerGuardBuilder::new(template)
638 }
639
640 /// Get a reference to the underlying template.
641 #[must_use]
642 pub fn template(&self) -> &T {
643 &self.template
644 }
645
646 /// Get the container ID, if available.
647 ///
648 /// This may be `None` if the container was reused from a previous run.
649 #[must_use]
650 pub fn container_id(&self) -> Option<&str> {
651 self.container_id.as_deref()
652 }
653
654 /// Check if this guard is reusing an existing container.
655 #[must_use]
656 pub fn was_reused(&self) -> bool {
657 self.was_reused
658 }
659
660 /// Get the network name, if one was configured.
661 #[must_use]
662 pub fn network(&self) -> Option<&str> {
663 self.options.network.as_deref()
664 }
665
666 /// Check if the container is currently running.
667 ///
668 /// # Errors
669 ///
670 /// Returns an error if the Docker command fails.
671 pub async fn is_running(&self) -> Result<bool, TemplateError> {
672 self.template.is_running().await
673 }
674
675 /// Wait for the container to be ready.
676 ///
677 /// This calls the underlying template's readiness check. The exact behavior
678 /// depends on the template implementation - for example, Redis templates
679 /// wait for a successful PING response.
680 ///
681 /// # Errors
682 ///
683 /// Returns an error if the readiness check times out or the Docker command fails.
684 ///
685 /// # Example
686 ///
687 /// ```rust,no_run
688 /// # use docker_wrapper::testing::ContainerGuard;
689 /// # use docker_wrapper::RedisTemplate;
690 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
691 /// let guard = ContainerGuard::new(RedisTemplate::new("test"))
692 /// .start()
693 /// .await?;
694 ///
695 /// // Wait for Redis to be ready to accept connections
696 /// guard.wait_for_ready().await?;
697 /// # Ok(())
698 /// # }
699 /// ```
700 pub async fn wait_for_ready(&self) -> Result<(), TemplateError> {
701 self.template.wait_for_ready().await
702 }
703
704 /// Get the host port mapped to a container port.
705 ///
706 /// This is useful when using dynamic port allocation - Docker assigns
707 /// a random available host port which you can query with this method.
708 ///
709 /// # Errors
710 ///
711 /// Returns an error if the Docker command fails or no port mapping is found.
712 ///
713 /// # Example
714 ///
715 /// ```rust,no_run
716 /// # use docker_wrapper::testing::ContainerGuard;
717 /// # use docker_wrapper::RedisTemplate;
718 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
719 /// let guard = ContainerGuard::new(RedisTemplate::new("test"))
720 /// .start()
721 /// .await?;
722 ///
723 /// let host_port = guard.host_port(6379).await?;
724 /// println!("Redis available at localhost:{}", host_port);
725 /// # Ok(())
726 /// # }
727 /// ```
728 pub async fn host_port(&self, container_port: u16) -> Result<u16, TemplateError> {
729 let container_name = self.template.config().name.clone();
730 let result = PortCommand::new(&container_name)
731 .port(container_port)
732 .run()
733 .await
734 .map_err(TemplateError::DockerError)?;
735
736 // Return the first matching port mapping
737 if let Some(mapping) = result.port_mappings.first() {
738 return Ok(mapping.host_port);
739 }
740
741 Err(TemplateError::InvalidConfig(format!(
742 "No host port mapping found for container port {container_port}"
743 )))
744 }
745
746 /// Get the container logs.
747 ///
748 /// # Errors
749 ///
750 /// Returns an error if the Docker command fails.
751 pub async fn logs(&self) -> Result<String, TemplateError> {
752 let container_name = self.template.config().name.clone();
753 let result = LogsCommand::new(&container_name)
754 .execute()
755 .await
756 .map_err(TemplateError::DockerError)?;
757
758 Ok(format!("{}{}", result.stdout, result.stderr))
759 }
760
761 /// Manually stop the container.
762 ///
763 /// The container will still be removed on drop if `remove_on_drop` is enabled.
764 ///
765 /// # Errors
766 ///
767 /// Returns an error if the Docker command fails.
768 pub async fn stop(&self) -> Result<(), TemplateError> {
769 self.template.stop().await
770 }
771
772 /// Manually clean up the container (stop and remove).
773 ///
774 /// After calling this, the drop implementation will not attempt cleanup again.
775 ///
776 /// # Errors
777 ///
778 /// Returns an error if the Docker commands fail.
779 pub async fn cleanup(&self) -> Result<(), TemplateError> {
780 if self.cleaned_up.swap(true, Ordering::SeqCst) {
781 return Ok(()); // Already cleaned up
782 }
783
784 if self.options.stop_on_drop {
785 let _ = self.template.stop().await;
786 }
787 if self.options.remove_on_drop {
788 let _ = self.template.remove().await;
789 }
790 Ok(())
791 }
792}
793
794/// Fault-injection helpers for chaos testing.
795///
796/// These are thin convenience wrappers over the underlying Docker commands
797/// ([`PauseCommand`], [`UnpauseCommand`], [`KillCommand`], [`RestartCommand`],
798/// [`NetworkConnectCommand`], [`NetworkDisconnectCommand`]) that target the
799/// guard's container by name. They make it easy to simulate real faults --
800/// hangs, crashes, and network partitions -- against a service under test
801/// without hand-rolling the command plumbing.
802///
803/// # Example
804///
805/// ```rust,no_run
806/// # use docker_wrapper::testing::ContainerGuard;
807/// # use docker_wrapper::RedisTemplate;
808/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
809/// let guard = ContainerGuard::new(RedisTemplate::new("redis"))
810/// .wait_for_ready(true)
811/// .start()
812/// .await?;
813///
814/// // Freeze the container's processes (simulate a hang), then resume.
815/// guard.pause().await?;
816/// guard.unpause().await?;
817///
818/// // Kill the container outright (simulate a crash).
819/// guard.crash().await?;
820/// # Ok(())
821/// # }
822/// ```
823impl<T: Template> ContainerGuard<T> {
824 /// Pause all processes in the container (simulate a hang).
825 ///
826 /// This freezes the container's processes using `docker pause`. They can be
827 /// resumed with [`unpause`](Self::unpause).
828 ///
829 /// # Errors
830 ///
831 /// Returns an error if the Docker command fails (for example, if the
832 /// container is not running).
833 pub async fn pause(&self) -> Result<(), TemplateError> {
834 let name = self.template.config().name.clone();
835 PauseCommand::new(name)
836 .run()
837 .await
838 .map_err(TemplateError::DockerError)?;
839 Ok(())
840 }
841
842 /// Resume a paused container.
843 ///
844 /// This is the inverse of [`pause`](Self::pause) and uses `docker unpause`.
845 ///
846 /// # Errors
847 ///
848 /// Returns an error if the Docker command fails (for example, if the
849 /// container is not paused).
850 pub async fn unpause(&self) -> Result<(), TemplateError> {
851 let name = self.template.config().name.clone();
852 UnpauseCommand::new(name)
853 .run()
854 .await
855 .map_err(TemplateError::DockerError)?;
856 Ok(())
857 }
858
859 /// Kill the container immediately with SIGKILL (simulate a crash).
860 ///
861 /// This sends `SIGKILL` via `docker kill`, terminating the container
862 /// without a graceful shutdown.
863 ///
864 /// # Errors
865 ///
866 /// Returns an error if the Docker command fails (for example, if the
867 /// container is not running).
868 pub async fn crash(&self) -> Result<(), TemplateError> {
869 let name = self.template.config().name.clone();
870 KillCommand::new(name)
871 .signal("SIGKILL")
872 .run()
873 .await
874 .map_err(TemplateError::DockerError)?;
875 Ok(())
876 }
877
878 /// Restart the container.
879 ///
880 /// This stops and starts the container via `docker restart`, which is useful
881 /// for simulating a recovery after a fault.
882 ///
883 /// # Errors
884 ///
885 /// Returns an error if the Docker command fails.
886 pub async fn restart_container(&self) -> Result<(), TemplateError> {
887 let name = self.template.config().name.clone();
888 RestartCommand::new(name)
889 .execute()
890 .await
891 .map_err(TemplateError::DockerError)?;
892 Ok(())
893 }
894
895 /// Partition the container from a network (simulate a network outage).
896 ///
897 /// This disconnects the container from `network` via `docker network
898 /// disconnect`, cutting it off from other containers on that network. Use
899 /// [`heal`](Self::heal) to reconnect.
900 ///
901 /// # Errors
902 ///
903 /// Returns an error if the Docker command fails (for example, if the
904 /// container is not attached to the network).
905 pub async fn partition(&self, network: impl Into<String>) -> Result<(), TemplateError> {
906 let name = self.template.config().name.clone();
907 NetworkDisconnectCommand::new(network.into(), name)
908 .force()
909 .run()
910 .await
911 .map_err(TemplateError::DockerError)?;
912 Ok(())
913 }
914
915 /// Reconnect the container to a network after a partition.
916 ///
917 /// This is the inverse of [`partition`](Self::partition) and uses `docker
918 /// network connect`.
919 ///
920 /// # Errors
921 ///
922 /// Returns an error if the Docker command fails.
923 pub async fn heal(&self, network: impl Into<String>) -> Result<(), TemplateError> {
924 let name = self.template.config().name.clone();
925 NetworkConnectCommand::new(network.into(), name)
926 .run()
927 .await
928 .map_err(TemplateError::DockerError)?;
929 Ok(())
930 }
931}
932
933impl<T: Template + HasConnectionString> ContainerGuard<T> {
934 /// Get the connection string for the underlying service.
935 ///
936 /// This is a convenience method that delegates to the template's
937 /// `connection_string()` implementation. The format depends on the
938 /// service type (e.g., `redis://host:port` for Redis).
939 ///
940 /// This method is only available for templates that implement
941 /// [`HasConnectionString`].
942 ///
943 /// # Example
944 ///
945 /// ```rust,no_run
946 /// # use docker_wrapper::testing::ContainerGuard;
947 /// # use docker_wrapper::RedisTemplate;
948 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
949 /// let guard = ContainerGuard::new(RedisTemplate::new("redis").port(6379))
950 /// .start()
951 /// .await?;
952 ///
953 /// // Direct access to connection string
954 /// let conn = guard.connection_string();
955 /// // Instead of: guard.template().connection_string()
956 /// # Ok(())
957 /// # }
958 /// ```
959 #[must_use]
960 pub fn connection_string(&self) -> String {
961 self.template.connection_string()
962 }
963}
964
965impl<T: Template> Drop for ContainerGuard<T> {
966 fn drop(&mut self) {
967 // Skip cleanup if already done
968 if self.cleaned_up.load(Ordering::SeqCst) {
969 return;
970 }
971
972 // Skip cleanup for reused containers if not configured to clean them
973 if self.was_reused && !self.options.remove_on_drop {
974 return;
975 }
976
977 // Check if we're panicking
978 let panicking = std::thread::panicking();
979
980 if panicking && self.options.keep_on_panic {
981 let name = &self.template.config().name;
982 eprintln!("[ContainerGuard] Test panicked, keeping container '{name}' for debugging");
983
984 if self.options.capture_logs {
985 // Try to get logs - spawn a thread to avoid runtime conflicts
986 let container_name = self.template.config().name.clone();
987 let _ = std::thread::spawn(move || {
988 if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
989 .enable_all()
990 .build()
991 {
992 if let Ok(result) =
993 rt.block_on(async { LogsCommand::new(&container_name).execute().await })
994 {
995 let logs = format!("{}{}", result.stdout, result.stderr);
996 eprintln!("[ContainerGuard] Container logs for '{container_name}':");
997 eprintln!("{logs}");
998 }
999 }
1000 })
1001 .join();
1002 }
1003 return;
1004 }
1005
1006 // Mark as cleaned up
1007 self.cleaned_up.store(true, Ordering::SeqCst);
1008
1009 // Perform cleanup - need to spawn a runtime since Drop isn't async
1010 let should_stop = self.options.stop_on_drop;
1011 let should_remove = self.options.remove_on_drop;
1012 let should_remove_network = self.options.remove_network_on_drop && self.network_created;
1013 let container_name = self.template.config().name.clone();
1014 let network_name = self.options.network.clone();
1015 let stop_timeout = self.options.stop_timeout;
1016
1017 if !should_stop && !should_remove && !should_remove_network {
1018 return;
1019 }
1020
1021 // Perform cleanup - try to use existing runtime if available,
1022 // otherwise create a new one (for non-async contexts)
1023 if tokio::runtime::Handle::try_current().is_ok() {
1024 // We're in an async context - use spawn_blocking to avoid blocking the runtime
1025 let container_name_clone = container_name.clone();
1026 let network_name_clone = network_name.clone();
1027 let _ = std::thread::spawn(move || {
1028 let rt = tokio::runtime::Builder::new_current_thread()
1029 .enable_all()
1030 .build()
1031 .expect("Failed to create runtime for cleanup");
1032 rt.block_on(async {
1033 if should_stop {
1034 let mut cmd = StopCommand::new(&container_name_clone);
1035 if let Some(timeout) = stop_timeout {
1036 cmd = cmd.timeout_duration(timeout);
1037 }
1038 let _ = cmd.execute().await;
1039 }
1040 if should_remove {
1041 let _ = RmCommand::new(&container_name_clone).force().run().await;
1042 }
1043 // Remove network after container (network must be empty)
1044 if should_remove_network {
1045 if let Some(ref network) = network_name_clone {
1046 let _ = NetworkRmCommand::new(network).execute().await;
1047 }
1048 }
1049 });
1050 })
1051 .join();
1052 } else {
1053 // Not in an async context - create a new runtime
1054 if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
1055 .enable_all()
1056 .build()
1057 {
1058 rt.block_on(async {
1059 if should_stop {
1060 let mut cmd = StopCommand::new(&container_name);
1061 if let Some(timeout) = stop_timeout {
1062 cmd = cmd.timeout_duration(timeout);
1063 }
1064 let _ = cmd.execute().await;
1065 }
1066 if should_remove {
1067 let _ = RmCommand::new(&container_name).force().run().await;
1068 }
1069 // Remove network after container (network must be empty)
1070 if should_remove_network {
1071 if let Some(ref network) = network_name {
1072 let _ = NetworkRmCommand::new(network).execute().await;
1073 }
1074 }
1075 });
1076 }
1077 }
1078 }
1079}
1080
1081/// A type-erased container guard entry for use in `ContainerGuardSet`.
1082///
1083/// This allows storing guards with different template types in the same collection.
1084#[allow(dead_code)]
1085struct GuardEntry {
1086 /// Container name for lookup
1087 name: String,
1088 /// Cleanup function to stop and remove the container
1089 cleanup_fn: Box<dyn FnOnce() + Send>,
1090}
1091
1092/// Options for `ContainerGuardSet`.
1093#[derive(Debug, Clone, Default)]
1094#[allow(clippy::struct_excessive_bools)]
1095pub struct GuardSetOptions {
1096 /// Shared network for all containers
1097 pub network: Option<String>,
1098 /// Create the network if it doesn't exist (default: true)
1099 pub create_network: bool,
1100 /// Remove the network on drop (default: true when network is set)
1101 pub remove_network_on_drop: bool,
1102 /// Keep containers running if test panics (default: false)
1103 pub keep_on_panic: bool,
1104 /// Wait for each container to be ready after starting (default: true)
1105 pub wait_for_ready: bool,
1106}
1107
1108impl GuardSetOptions {
1109 fn new() -> Self {
1110 Self {
1111 network: None,
1112 create_network: true,
1113 remove_network_on_drop: true,
1114 keep_on_panic: false,
1115 wait_for_ready: true,
1116 }
1117 }
1118}
1119
1120/// A pending template entry waiting to be started.
1121struct PendingEntry<T: Template + 'static> {
1122 template: T,
1123}
1124
1125/// Type-erased pending entry trait.
1126trait PendingEntryTrait: Send {
1127 /// Get the container name
1128 fn name(&self) -> String;
1129 /// Start the container and return a cleanup function
1130 fn start(
1131 self: Box<Self>,
1132 network: Option<String>,
1133 wait_for_ready: bool,
1134 keep_on_panic: bool,
1135 ) -> std::pin::Pin<
1136 Box<dyn std::future::Future<Output = Result<GuardEntry, TemplateError>> + Send>,
1137 >;
1138}
1139
1140impl<T: Template + 'static> PendingEntryTrait for PendingEntry<T> {
1141 fn name(&self) -> String {
1142 self.template.config().name.clone()
1143 }
1144
1145 fn start(
1146 self: Box<Self>,
1147 network: Option<String>,
1148 wait_for_ready: bool,
1149 keep_on_panic: bool,
1150 ) -> std::pin::Pin<
1151 Box<dyn std::future::Future<Output = Result<GuardEntry, TemplateError>> + Send>,
1152 > {
1153 Box::pin(async move {
1154 let mut template = self.template;
1155 let name = template.config().name.clone();
1156
1157 // Set network if provided
1158 if let Some(ref net) = network {
1159 template.config_mut().network = Some(net.clone());
1160 }
1161
1162 // Start the container
1163 template.start_and_wait().await?;
1164
1165 // Wait for ready if configured
1166 if wait_for_ready {
1167 template.wait_for_ready().await?;
1168 }
1169
1170 // Create cleanup function
1171 let cleanup_name = name.clone();
1172 let cleanup_fn: Box<dyn FnOnce() + Send> = Box::new(move || {
1173 // Check if panicking and should keep
1174 if std::thread::panicking() && keep_on_panic {
1175 eprintln!(
1176 "[ContainerGuardSet] Test panicked, keeping container '{cleanup_name}' for debugging"
1177 );
1178 return;
1179 }
1180
1181 // Perform cleanup in a new runtime
1182 let _ = std::thread::spawn(move || {
1183 if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
1184 .enable_all()
1185 .build()
1186 {
1187 rt.block_on(async {
1188 let _ = StopCommand::new(&cleanup_name).execute().await;
1189 let _ = RmCommand::new(&cleanup_name).force().run().await;
1190 });
1191 }
1192 })
1193 .join();
1194 });
1195
1196 Ok(GuardEntry { name, cleanup_fn })
1197 })
1198 }
1199}
1200
1201/// Builder for creating a [`ContainerGuardSet`].
1202///
1203/// # Example
1204///
1205/// ```rust,no_run
1206/// use docker_wrapper::testing::ContainerGuardSet;
1207/// use docker_wrapper::RedisTemplate;
1208///
1209/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1210/// let guards = ContainerGuardSet::new()
1211/// .with_network("test-network")
1212/// .add(RedisTemplate::new("redis-1"))
1213/// .add(RedisTemplate::new("redis-2"))
1214/// .start_all()
1215/// .await?;
1216///
1217/// // Access by name
1218/// assert!(guards.contains("redis-1"));
1219/// # Ok(())
1220/// # }
1221/// ```
1222pub struct ContainerGuardSetBuilder {
1223 entries: Vec<Box<dyn PendingEntryTrait>>,
1224 options: GuardSetOptions,
1225}
1226
1227impl ContainerGuardSetBuilder {
1228 /// Create a new builder.
1229 #[must_use]
1230 pub fn new() -> Self {
1231 Self {
1232 entries: Vec::new(),
1233 options: GuardSetOptions::new(),
1234 }
1235 }
1236
1237 /// Add a template to the set.
1238 ///
1239 /// The container name from the template's config is used as the key for lookup.
1240 #[allow(clippy::should_implement_trait)]
1241 #[must_use]
1242 pub fn add<T: Template + 'static>(mut self, template: T) -> Self {
1243 self.entries.push(Box::new(PendingEntry { template }));
1244 self
1245 }
1246
1247 /// Set a shared network for all containers.
1248 ///
1249 /// The network will be created if it doesn't exist (unless `create_network(false)` is called).
1250 #[must_use]
1251 pub fn with_network(mut self, network: impl Into<String>) -> Self {
1252 self.options.network = Some(network.into());
1253 self
1254 }
1255
1256 /// Set whether to create the network if it doesn't exist (default: true).
1257 #[must_use]
1258 pub fn create_network(mut self, create: bool) -> Self {
1259 self.options.create_network = create;
1260 self
1261 }
1262
1263 /// Set whether to remove the network on drop (default: true when network is set).
1264 #[must_use]
1265 pub fn remove_network_on_drop(mut self, remove: bool) -> Self {
1266 self.options.remove_network_on_drop = remove;
1267 self
1268 }
1269
1270 /// Set whether to keep containers running if the test panics (default: false).
1271 #[must_use]
1272 pub fn keep_on_panic(mut self, keep: bool) -> Self {
1273 self.options.keep_on_panic = keep;
1274 self
1275 }
1276
1277 /// Set whether to wait for each container to be ready (default: true).
1278 #[must_use]
1279 pub fn wait_for_ready(mut self, wait: bool) -> Self {
1280 self.options.wait_for_ready = wait;
1281 self
1282 }
1283
1284 /// Start all containers and return a guard set.
1285 ///
1286 /// Containers are started sequentially in the order they were added.
1287 ///
1288 /// # Errors
1289 ///
1290 /// Returns an error if any container fails to start. Containers that were
1291 /// successfully started before the failure will be cleaned up.
1292 pub async fn start_all(self) -> Result<ContainerGuardSet, TemplateError> {
1293 let mut network_created = false;
1294
1295 // Create network if needed
1296 if let Some(ref network) = self.options.network {
1297 if self.options.create_network {
1298 let result = NetworkCreateCommand::new(network)
1299 .driver("bridge")
1300 .execute()
1301 .await;
1302 network_created = result.is_ok();
1303 }
1304 }
1305
1306 let mut guards: Vec<GuardEntry> = Vec::new();
1307 let mut names: HashMap<String, usize> = HashMap::new();
1308
1309 // Start each container
1310 for entry in self.entries {
1311 let name = entry.name();
1312 match entry
1313 .start(
1314 self.options.network.clone(),
1315 self.options.wait_for_ready,
1316 self.options.keep_on_panic,
1317 )
1318 .await
1319 {
1320 Ok(guard) => {
1321 names.insert(name, guards.len());
1322 guards.push(guard);
1323 }
1324 Err(e) => {
1325 // Clean up already-started containers on failure
1326 for guard in guards {
1327 (guard.cleanup_fn)();
1328 }
1329 // Clean up network if we created it
1330 if network_created {
1331 if let Some(ref network) = self.options.network {
1332 let net = network.clone();
1333 let _ = std::thread::spawn(move || {
1334 if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
1335 .enable_all()
1336 .build()
1337 {
1338 rt.block_on(async {
1339 let _ = NetworkRmCommand::new(&net).execute().await;
1340 });
1341 }
1342 })
1343 .join();
1344 }
1345 }
1346 return Err(e);
1347 }
1348 }
1349 }
1350
1351 Ok(ContainerGuardSet {
1352 guards,
1353 names,
1354 options: self.options,
1355 network_created,
1356 })
1357 }
1358}
1359
1360impl Default for ContainerGuardSetBuilder {
1361 fn default() -> Self {
1362 Self::new()
1363 }
1364}
1365
1366/// Manages multiple containers as a group with coordinated lifecycle.
1367///
1368/// All containers are cleaned up when the set is dropped. This is useful for
1369/// integration tests that require multiple services.
1370///
1371/// # Example
1372///
1373/// ```rust,no_run
1374/// use docker_wrapper::testing::ContainerGuardSet;
1375/// use docker_wrapper::RedisTemplate;
1376///
1377/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1378/// let guards = ContainerGuardSet::new()
1379/// .with_network("test-network")
1380/// .add(RedisTemplate::new("redis"))
1381/// .keep_on_panic(true)
1382/// .start_all()
1383/// .await?;
1384///
1385/// // Check if container exists
1386/// assert!(guards.contains("redis"));
1387///
1388/// // Get container names
1389/// for name in guards.names() {
1390/// println!("Container: {}", name);
1391/// }
1392/// # Ok(())
1393/// # }
1394/// ```
1395pub struct ContainerGuardSet {
1396 guards: Vec<GuardEntry>,
1397 names: HashMap<String, usize>,
1398 options: GuardSetOptions,
1399 network_created: bool,
1400}
1401
1402impl ContainerGuardSet {
1403 /// Create a new builder for a container guard set.
1404 #[allow(clippy::new_ret_no_self)]
1405 #[must_use]
1406 pub fn new() -> ContainerGuardSetBuilder {
1407 ContainerGuardSetBuilder::new()
1408 }
1409
1410 /// Check if a container with the given name exists in the set.
1411 #[must_use]
1412 pub fn contains(&self, name: &str) -> bool {
1413 self.names.contains_key(name)
1414 }
1415
1416 /// Get an iterator over container names in the set.
1417 pub fn names(&self) -> impl Iterator<Item = &str> {
1418 self.names.keys().map(String::as_str)
1419 }
1420
1421 /// Get the number of containers in the set.
1422 #[must_use]
1423 pub fn len(&self) -> usize {
1424 self.guards.len()
1425 }
1426
1427 /// Check if the set is empty.
1428 #[must_use]
1429 pub fn is_empty(&self) -> bool {
1430 self.guards.is_empty()
1431 }
1432
1433 /// Get the shared network name, if one was configured.
1434 #[must_use]
1435 pub fn network(&self) -> Option<&str> {
1436 self.options.network.as_deref()
1437 }
1438}
1439
1440impl Default for ContainerGuardSet {
1441 fn default() -> Self {
1442 Self {
1443 guards: Vec::new(),
1444 names: HashMap::new(),
1445 options: GuardSetOptions::new(),
1446 network_created: false,
1447 }
1448 }
1449}
1450
1451impl Drop for ContainerGuardSet {
1452 fn drop(&mut self) {
1453 // Clean up all containers
1454 for guard in self.guards.drain(..) {
1455 (guard.cleanup_fn)();
1456 }
1457
1458 // Clean up network if we created it
1459 if self.network_created && self.options.remove_network_on_drop {
1460 if let Some(ref network) = self.options.network {
1461 let net = network.clone();
1462 let _ = std::thread::spawn(move || {
1463 if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
1464 .enable_all()
1465 .build()
1466 {
1467 rt.block_on(async {
1468 let _ = NetworkRmCommand::new(&net).execute().await;
1469 });
1470 }
1471 })
1472 .join();
1473 }
1474 }
1475 }
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480 use super::*;
1481
1482 #[test]
1483 fn test_guard_options_default() {
1484 let opts = GuardOptions::default();
1485 assert!(opts.remove_on_drop);
1486 assert!(opts.stop_on_drop);
1487 assert!(!opts.keep_on_panic);
1488 assert!(!opts.capture_logs);
1489 assert!(!opts.reuse_if_running);
1490 assert!(!opts.wait_for_ready);
1491 assert!(opts.network.is_none());
1492 assert!(opts.create_network);
1493 assert!(!opts.remove_network_on_drop);
1494 assert!(opts.stop_timeout.is_none());
1495 }
1496
1497 #[test]
1498 fn test_builder_options() {
1499 // We can't easily test the builder without a real template,
1500 // but we can at least verify the module compiles
1501 }
1502}