1use crate::backend::volume::{VolumeMount, VolumeValidator};
7use crate::backend::{Backend, Cmd, RunResult};
8use crate::error::{BackendError, Result};
9use crate::policy::Policy;
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12use testcontainers::{core::ExecCommand, runners::SyncRunner, GenericImage, ImageExt};
13
14#[cfg(feature = "otel-traces")]
15use tracing::{info, instrument, warn};
16
17#[derive(Debug, Clone)]
19pub struct TestcontainerBackend {
20 image_name: String,
22 image_tag: String,
23 policy: Policy,
25 timeout: Duration,
27 startup_timeout: Duration,
29 env_vars: std::collections::HashMap<String, String>,
31 default_command: Option<Vec<String>>,
33 volume_mounts: Vec<VolumeMount>,
35 volume_validator: Arc<VolumeValidator>,
37 memory_limit: Option<u64>,
39 cpu_limit: Option<f64>,
41}
42
43impl TestcontainerBackend {
44 pub fn new(image: impl Into<String>) -> Result<Self> {
46 let image_str = image.into();
47
48 let (image_name, image_tag) = if let Some((name, tag)) = image_str.split_once(':') {
50 (name.to_string(), tag.to_string())
51 } else {
52 (image_str, "latest".to_string())
53 };
54
55 Ok(Self {
56 image_name,
57 image_tag,
58 policy: Policy::default(),
59 timeout: Duration::from_secs(30), startup_timeout: Duration::from_secs(10), env_vars: std::collections::HashMap::new(),
62 default_command: None,
63 volume_mounts: Vec::new(),
64 volume_validator: Arc::new(VolumeValidator::default()),
65 memory_limit: None,
66 cpu_limit: None,
67 })
68 }
69
70 pub fn with_policy(mut self, policy: Policy) -> Self {
72 self.policy = policy;
73 self
74 }
75
76 pub fn with_timeout(mut self, timeout: Duration) -> Self {
78 self.timeout = timeout;
79 self
80 }
81
82 pub fn with_startup_timeout(mut self, timeout: Duration) -> Self {
84 self.startup_timeout = timeout;
85 self
86 }
87
88 pub fn is_running(&self) -> bool {
90 true
93 }
94
95 pub fn with_env(mut self, key: &str, val: &str) -> Self {
97 self.env_vars.insert(key.to_string(), val.to_string());
98 self
99 }
100
101 pub fn with_cmd(mut self, cmd: Vec<String>) -> Self {
103 self.default_command = Some(cmd);
104 self
105 }
106
107 pub fn with_volume(
119 mut self,
120 host_path: &str,
121 container_path: &str,
122 read_only: bool,
123 ) -> Result<Self> {
124 let mount = VolumeMount::new(host_path, container_path, read_only)?;
125 self.volume_validator.validate(&mount)?;
126 self.volume_mounts.push(mount);
127 Ok(self)
128 }
129
130 pub fn with_volume_ro(self, host_path: &str, container_path: &str) -> Result<Self> {
134 self.with_volume(host_path, container_path, true)
135 }
136
137 pub fn with_volume_validator(mut self, validator: VolumeValidator) -> Self {
139 self.volume_validator = Arc::new(validator);
140 self
141 }
142
143 pub fn volumes(&self) -> &[VolumeMount] {
145 &self.volume_mounts
146 }
147
148 pub fn with_memory_limit(mut self, limit_mb: u64) -> Self {
150 self.memory_limit = Some(limit_mb);
151 self
152 }
153
154 pub fn with_cpu_limit(mut self, cpus: f64) -> Self {
156 self.cpu_limit = Some(cpus);
157 self
158 }
159
160 pub fn is_available() -> bool {
162 true
164 }
165
166 #[cfg(feature = "otel-traces")]
174 pub fn validate_otel_instrumentation(&self) -> Result<bool> {
175 use crate::telemetry::validation::is_otel_initialized;
177
178 if !is_otel_initialized() {
179 return Err(crate::error::CleanroomError::validation_error(
180 "OpenTelemetry is not initialized. Enable OTEL features and call init_otel()",
181 ));
182 }
183
184 Ok(true)
187 }
188
189 #[cfg(feature = "otel-traces")]
191 pub fn otel_validation_enabled(&self) -> bool {
192 true
193 }
194
195 #[cfg(not(feature = "otel-traces"))]
196 pub fn otel_validation_enabled(&self) -> bool {
197 false
198 }
199
200 #[cfg_attr(feature = "otel-traces", instrument(name = "testcontainer.execute", skip(self, cmd), fields(image = %self.image_name, tag = %self.image_tag)))]
202 fn execute_in_container(&self, cmd: &Cmd) -> Result<RunResult> {
203 let start_time = Instant::now();
204
205 #[cfg(feature = "otel-traces")]
206 info!(
207 "Starting container with image {}:{}",
208 self.image_name, self.image_tag
209 );
210
211 let image = GenericImage::new(self.image_name.clone(), self.image_tag.clone());
215
216 let mut container_request: testcontainers::core::ContainerRequest<
218 testcontainers::GenericImage,
219 > = image.into();
220
221 for (key, value) in &self.env_vars {
223 container_request = container_request.with_env_var(key, value);
224 }
225
226 for (key, value) in &cmd.env {
228 container_request = container_request.with_env_var(key, value);
229 }
230
231 for (key, value) in self.policy.to_env() {
233 container_request = container_request.with_env_var(key, value);
234 }
235
236 for mount in &self.volume_mounts {
238 use testcontainers::core::{AccessMode, Mount};
239
240 let access_mode = if mount.is_read_only() {
241 AccessMode::ReadOnly
242 } else {
243 AccessMode::ReadWrite
244 };
245
246 let bind_mount = Mount::bind_mount(
247 mount.host_path().to_string_lossy().to_string(),
248 mount.container_path().to_string_lossy().to_string(),
249 )
250 .with_access_mode(access_mode);
251
252 container_request = container_request.with_mount(bind_mount);
253 }
254
255 container_request = container_request.with_cmd(vec!["sleep", "3600"]);
258
259 if let Some(workdir) = &cmd.workdir {
261 container_request =
262 container_request.with_working_dir(workdir.to_string_lossy().to_string());
263 }
264
265 let container_start_time = Instant::now();
267 let container = container_request
268 .start()
269 .map_err(|e| {
270 let elapsed = container_start_time.elapsed();
271 if elapsed > Duration::from_secs(10) {
272 #[cfg(feature = "otel-traces")]
273 warn!("Container startup took {}s, which is longer than expected. First pull of image may take time.", elapsed.as_secs());
274 }
275
276 BackendError::Runtime(format!(
277 "Failed to start container with image '{}:{}' after {}s.\n\
278 Possible causes:\n\
279 - Docker daemon not running (try: docker ps)\n\
280 - Image needs to be pulled (first run may take longer)\n\
281 - Network issues preventing image pull\n\
282 Try: Increase startup timeout or check Docker status\n\
283 Original error: {}",
284 self.image_name, self.image_tag, elapsed.as_secs(), e
285 ))
286 })?;
287
288 #[cfg(feature = "otel-traces")]
289 info!("Container started successfully, executing command");
290
291 let cmd_args: Vec<&str> = std::iter::once(cmd.bin.as_str())
293 .chain(cmd.args.iter().map(|s| s.as_str()))
294 .collect();
295
296 let exec_cmd = ExecCommand::new(cmd_args);
297 let mut exec_result = container
298 .exec(exec_cmd)
299 .map_err(|e| BackendError::Runtime(format!("Command execution failed: {}", e)))?;
300
301 let duration_ms = start_time.elapsed().as_millis() as u64;
302
303 #[cfg(feature = "otel-traces")]
304 info!("Command completed in {}ms", duration_ms);
305
306 use std::io::Read;
308 let mut stdout = String::new();
309 let mut stderr = String::new();
310
311 exec_result
312 .stdout()
313 .read_to_string(&mut stdout)
314 .map_err(|e| BackendError::Runtime(format!("Failed to read stdout: {}", e)))?;
315 exec_result
316 .stderr()
317 .read_to_string(&mut stderr)
318 .map_err(|e| BackendError::Runtime(format!("Failed to read stderr: {}", e)))?;
319
320 let exit_code = exec_result.exit_code().unwrap_or(Some(-1)).unwrap_or(-1) as i32;
321
322 Ok(RunResult {
323 exit_code,
324 stdout,
325 stderr,
326 duration_ms,
327 steps: Vec::new(),
328 redacted_env: Vec::new(),
329 backend: "testcontainers".to_string(),
330 concurrent: false,
331 step_order: Vec::new(),
332 })
333 }
334}
335
336impl Backend for TestcontainerBackend {
337 fn run_cmd(&self, cmd: Cmd) -> Result<RunResult> {
338 let start_time = Instant::now();
340
341 let result = self.execute_in_container(&cmd)?;
343
344 if start_time.elapsed() > self.timeout {
346 return Err(crate::error::CleanroomError::timeout_error(format!(
347 "Command execution timed out after {} seconds",
348 self.timeout.as_secs()
349 )));
350 }
351
352 Ok(result)
353 }
354
355 fn name(&self) -> &str {
356 "testcontainers"
357 }
358
359 fn is_available(&self) -> bool {
360 Self::is_available()
361 }
362
363 fn supports_hermetic(&self) -> bool {
364 true
365 }
366
367 fn supports_deterministic(&self) -> bool {
368 true
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn test_testcontainer_backend_creation() {
378 let backend = TestcontainerBackend::new("alpine:latest");
379 assert!(backend.is_ok());
380 }
381
382 #[test]
383 fn test_testcontainer_backend_with_timeout() -> Result<()> {
384 let timeout = Duration::from_secs(60);
385 let backend = TestcontainerBackend::new("alpine:latest")?.with_timeout(timeout);
386 assert!(backend.is_running());
387 Ok(())
388 }
389
390 #[test]
391 fn test_testcontainer_backend_trait() -> Result<()> {
392 let backend = TestcontainerBackend::new("alpine:latest")?;
393 assert!(backend.is_running());
394 Ok(())
395 }
396
397 #[test]
398 fn test_testcontainer_backend_image() -> Result<()> {
399 let backend = TestcontainerBackend::new("ubuntu:20.04")?;
400 assert!(backend.is_running());
401 Ok(())
402 }
403}
404
405#[cfg(test)]
406mod volume_tests {
407 use super::*;
408 use crate::error::CleanroomError;
409
410 #[test]
415 fn test_with_volume_adds_mount_to_backend() -> Result<()> {
416 let temp_dir = std::env::temp_dir();
418 let host_path = temp_dir.join("test_mount");
419 std::fs::create_dir_all(&host_path)?;
420
421 let backend = TestcontainerBackend::new("alpine:latest")?;
422
423 let backend_with_volume = backend.with_volume(
425 host_path.to_str().ok_or_else(|| {
426 CleanroomError::internal_error("Invalid host path - contains non-UTF8 characters")
427 })?,
428 "/container/path",
429 false,
430 )?;
431
432 assert_eq!(backend_with_volume.volume_mounts.len(), 1);
434 assert_eq!(
435 backend_with_volume.volume_mounts[0]
436 .container_path()
437 .to_str()
438 .unwrap_or("invalid"),
439 "/container/path"
440 );
441
442 std::fs::remove_dir(&host_path)?;
443 Ok(())
444 }
445
446 #[test]
447 fn test_with_volume_supports_multiple_volumes() -> Result<()> {
448 let temp_dir = std::env::temp_dir();
450 let data_path = temp_dir.join("data");
451 let config_path = temp_dir.join("config");
452 let output_path = temp_dir.join("output");
453
454 std::fs::create_dir_all(&data_path)?;
455 std::fs::create_dir_all(&config_path)?;
456 std::fs::create_dir_all(&output_path)?;
457
458 let backend = TestcontainerBackend::new("alpine:latest")?;
459
460 let backend_with_volumes = backend
462 .with_volume(
463 data_path.to_str().ok_or_else(|| {
464 crate::error::CleanroomError::internal_error("Invalid data path")
465 })?,
466 "/data",
467 false,
468 )?
469 .with_volume(
470 config_path.to_str().ok_or_else(|| {
471 crate::error::CleanroomError::internal_error("Invalid config path")
472 })?,
473 "/config",
474 false,
475 )?
476 .with_volume(
477 output_path.to_str().ok_or_else(|| {
478 crate::error::CleanroomError::internal_error("Invalid output path")
479 })?,
480 "/output",
481 false,
482 )?;
483
484 assert_eq!(backend_with_volumes.volume_mounts.len(), 3);
486
487 std::fs::remove_dir(&data_path)?;
488 std::fs::remove_dir(&config_path)?;
489 std::fs::remove_dir(&output_path)?;
490 Ok(())
491 }
492
493 #[test]
494 fn test_with_volume_preserves_other_settings() -> Result<()> {
495 let temp_dir = std::env::temp_dir();
497 let test_path = temp_dir.join("test");
498 std::fs::create_dir_all(&test_path)?;
499
500 let timeout = Duration::from_secs(120);
501 let backend = TestcontainerBackend::new("alpine:latest")?
502 .with_timeout(timeout)
503 .with_env("TEST_VAR", "test_value");
504
505 let backend_with_volume = backend.with_volume(
507 test_path
508 .to_str()
509 .ok_or_else(|| crate::error::CleanroomError::internal_error("Invalid test path"))?,
510 "/test",
511 false,
512 )?;
513
514 assert_eq!(backend_with_volume.timeout, timeout);
516 assert_eq!(
517 backend_with_volume.env_vars.get("TEST_VAR"),
518 Some(&"test_value".to_string())
519 );
520 assert_eq!(backend_with_volume.volume_mounts.len(), 1);
521
522 std::fs::remove_dir(&test_path)?;
523 Ok(())
524 }
525
526 #[test]
531 fn test_with_volume_accepts_absolute_host_paths() -> Result<()> {
532 let temp_dir = std::env::temp_dir();
534 let abs_path = temp_dir.join("absolute");
535 std::fs::create_dir_all(&abs_path)?;
536
537 let backend = TestcontainerBackend::new("alpine:latest")?;
538
539 let backend_with_volume = backend.with_volume(
541 abs_path.to_str().ok_or_else(|| {
542 crate::error::CleanroomError::internal_error("Invalid absolute path")
543 })?,
544 "/container/path",
545 false,
546 )?;
547
548 assert!(backend_with_volume.volume_mounts[0]
550 .host_path()
551 .is_absolute());
552
553 std::fs::remove_dir(&abs_path)?;
554 Ok(())
555 }
556
557 #[test]
558 fn test_with_volume_rejects_relative_container_paths() -> Result<()> {
559 let temp_dir = std::env::temp_dir();
561 let backend = TestcontainerBackend::new("alpine:latest")?;
562
563 let result = backend.with_volume(
565 temp_dir.to_str().ok_or_else(|| {
566 crate::error::CleanroomError::internal_error("Invalid temp dir path")
567 })?,
568 "relative/container/path",
569 false,
570 );
571
572 assert!(result.is_err());
574 Ok(())
575 }
576
577 #[test]
578 fn test_with_volume_handles_special_characters_in_paths() -> Result<()> {
579 let temp_dir = std::env::temp_dir();
581 let special_path = temp_dir.join("test-data_v1.0");
582 std::fs::create_dir_all(&special_path)?;
583
584 let backend = TestcontainerBackend::new("alpine:latest")?;
585
586 let backend_with_volume = backend.with_volume(
588 special_path.to_str().ok_or_else(|| {
589 crate::error::CleanroomError::internal_error("Invalid special path")
590 })?,
591 "/container/test-data/v1.0",
592 false,
593 )?;
594
595 assert_eq!(backend_with_volume.volume_mounts.len(), 1);
597
598 std::fs::remove_dir(&special_path)?;
599 Ok(())
600 }
601
602 #[test]
603 fn test_with_volume_rejects_empty_strings() -> Result<()> {
604 let backend = TestcontainerBackend::new("alpine:latest")?;
606
607 let result = backend.with_volume("", "", false);
609
610 assert!(result.is_err());
612 Ok(())
613 }
614
615 #[test]
620 fn test_volume_builder_chain_with_other_methods() -> Result<()> {
621 let temp_dir = std::env::temp_dir();
623 let data_path = temp_dir.join("data_chain");
624 std::fs::create_dir_all(&data_path)?;
625
626 let backend = TestcontainerBackend::new("alpine:latest")?
627 .with_policy(Policy::default())
628 .with_timeout(Duration::from_secs(60))
629 .with_env("ENV_VAR", "value")
630 .with_volume(
631 data_path.to_str().ok_or_else(|| {
632 CleanroomError::internal_error("Invalid path for volume mount")
633 })?,
634 "/data",
635 false,
636 )?
637 .with_memory_limit(512)
638 .with_cpu_limit(1.0);
639
640 assert_eq!(backend.volume_mounts.len(), 1);
642 assert_eq!(backend.timeout, Duration::from_secs(60));
643 assert_eq!(backend.memory_limit, Some(512));
644 assert_eq!(backend.cpu_limit, Some(1.0));
645
646 std::fs::remove_dir(&data_path)?;
647 Ok(())
648 }
649
650 #[test]
651 fn test_volume_builder_immutability() -> Result<()> {
652 let temp_dir = std::env::temp_dir();
654 let test1_path = temp_dir.join("test1");
655 let test2_path = temp_dir.join("test2");
656 std::fs::create_dir_all(&test1_path)?;
657 std::fs::create_dir_all(&test2_path)?;
658
659 let backend1 = TestcontainerBackend::new("alpine:latest")?;
660
661 let backend2 = backend1.clone().with_volume(
663 test1_path.to_str().ok_or_else(|| {
664 crate::error::CleanroomError::internal_error("Invalid test1 path")
665 })?,
666 "/test1",
667 false,
668 )?;
669 let backend3 = backend1.clone().with_volume(
670 test2_path.to_str().ok_or_else(|| {
671 crate::error::CleanroomError::internal_error("Invalid test2 path")
672 })?,
673 "/test2",
674 false,
675 )?;
676
677 assert_eq!(backend2.volume_mounts.len(), 1);
679 assert_eq!(backend3.volume_mounts.len(), 1);
680 assert_ne!(
681 backend2.volume_mounts[0].container_path(),
682 backend3.volume_mounts[0].container_path()
683 );
684
685 std::fs::remove_dir(&test1_path)?;
686 std::fs::remove_dir(&test2_path)?;
687 Ok(())
688 }
689
690 #[test]
695 fn test_with_volume_duplicate_mounts_allowed() -> Result<()> {
696 let temp_dir = std::env::temp_dir();
698 let data_path = temp_dir.join("data_dup");
699 std::fs::create_dir_all(&data_path)?;
700
701 let backend = TestcontainerBackend::new("alpine:latest")?;
702
703 let backend_with_volumes = backend
705 .with_volume(
706 data_path.to_str().ok_or_else(|| {
707 crate::error::CleanroomError::internal_error("Invalid data path")
708 })?,
709 "/data",
710 false,
711 )?
712 .with_volume(
713 data_path.to_str().ok_or_else(|| {
714 crate::error::CleanroomError::internal_error("Invalid data path")
715 })?,
716 "/data",
717 false,
718 )?;
719
720 assert_eq!(backend_with_volumes.volume_mounts.len(), 2);
722
723 std::fs::remove_dir(&data_path)?;
724 Ok(())
725 }
726
727 #[test]
728 fn test_with_volume_overlapping_container_paths() -> Result<()> {
729 let temp_dir = std::env::temp_dir();
731 let data1_path = temp_dir.join("data1");
732 let data2_path = temp_dir.join("data2");
733 std::fs::create_dir_all(&data1_path)?;
734 std::fs::create_dir_all(&data2_path)?;
735
736 let backend = TestcontainerBackend::new("alpine:latest")?;
737
738 let backend_with_volumes = backend
740 .with_volume(
741 data1_path.to_str().ok_or_else(|| {
742 crate::error::CleanroomError::internal_error("Invalid data1 path")
743 })?,
744 "/shared",
745 false,
746 )?
747 .with_volume(
748 data2_path.to_str().ok_or_else(|| {
749 crate::error::CleanroomError::internal_error("Invalid data2 path")
750 })?,
751 "/shared",
752 false,
753 )?;
754
755 assert_eq!(backend_with_volumes.volume_mounts.len(), 2);
757
758 std::fs::remove_dir(&data1_path)?;
759 std::fs::remove_dir(&data2_path)?;
760 Ok(())
761 }
762
763 #[test]
764 fn test_with_volume_very_long_paths() -> Result<()> {
765 let temp_dir = std::env::temp_dir();
767 let long_name = "a".repeat(100); let long_path = temp_dir.join(long_name);
769 std::fs::create_dir_all(&long_path)?;
770
771 let backend = TestcontainerBackend::new("alpine:latest")?;
772
773 let backend_with_volume = backend.with_volume(
775 long_path
776 .to_str()
777 .ok_or_else(|| crate::error::CleanroomError::internal_error("Invalid long path"))?,
778 "/data",
779 false,
780 )?;
781
782 assert!(
784 backend_with_volume.volume_mounts[0]
785 .host_path()
786 .to_str()
787 .unwrap_or("")
788 .len()
789 > 100
790 );
791
792 std::fs::remove_dir(&long_path)?;
793 Ok(())
794 }
795
796 #[test]
797 #[cfg(target_os = "linux")]
798 fn test_with_volume_unicode_paths() -> Result<()> {
799 let temp_dir = std::env::temp_dir();
801 let unicode_path = temp_dir.join("données");
802 std::fs::create_dir_all(&unicode_path)?;
803
804 let backend = TestcontainerBackend::new("alpine:latest")?;
805
806 let backend_with_volume = backend.with_volume(
808 unicode_path.to_str().ok_or_else(|| {
809 crate::error::CleanroomError::internal_error("Invalid unicode path")
810 })?,
811 "/container/データ",
812 false,
813 )?;
814
815 assert_eq!(backend_with_volume.volume_mounts.len(), 1);
817
818 std::fs::remove_dir(&unicode_path)?;
819 Ok(())
820 }
821
822 #[test]
827 fn test_volume_mounts_per_backend_instance_isolated() -> Result<()> {
828 let temp_dir = std::env::temp_dir();
830 let backend1_path = temp_dir.join("backend1");
831 let backend2_path = temp_dir.join("backend2");
832 std::fs::create_dir_all(&backend1_path)?;
833 std::fs::create_dir_all(&backend2_path)?;
834
835 let backend1 = TestcontainerBackend::new("alpine:latest")?.with_volume(
836 backend1_path.to_str().ok_or_else(|| {
837 crate::error::CleanroomError::internal_error("Invalid backend1 path")
838 })?,
839 "/data",
840 false,
841 )?;
842 let backend2 = TestcontainerBackend::new("alpine:latest")?.with_volume(
843 backend2_path.to_str().ok_or_else(|| {
844 crate::error::CleanroomError::internal_error("Invalid backend2 path")
845 })?,
846 "/data",
847 false,
848 )?;
849
850 assert_eq!(backend1.volume_mounts.len(), 1);
852 assert_eq!(backend2.volume_mounts.len(), 1);
853 assert_ne!(
854 backend1.volume_mounts[0].host_path(),
855 backend2.volume_mounts[0].host_path()
856 );
857
858 std::fs::remove_dir(&backend1_path)?;
859 std::fs::remove_dir(&backend2_path)?;
860 Ok(())
861 }
862
863 #[test]
868 fn test_volume_mounts_storage_format() -> Result<()> {
869 let temp_dir = std::env::temp_dir();
871 let host_path = temp_dir.join("storage_test");
872 std::fs::create_dir_all(&host_path)?;
873
874 let backend = TestcontainerBackend::new("alpine:latest")?.with_volume(
875 host_path
876 .to_str()
877 .ok_or_else(|| crate::error::CleanroomError::internal_error("Invalid host path"))?,
878 "/container/path",
879 false,
880 )?;
881
882 assert_eq!(
884 backend.volume_mounts[0].container_path(),
885 std::path::Path::new("/container/path")
886 );
887
888 std::fs::remove_dir(&host_path)?;
889 Ok(())
890 }
891
892 #[test]
893 fn test_empty_volume_mounts_by_default() -> Result<()> {
894 let backend = TestcontainerBackend::new("alpine:latest")?;
896
897 assert!(backend.volume_mounts.is_empty());
899 Ok(())
900 }
901}