1use crate::backend::{Backend, Cmd, RunResult};
7use crate::error::{BackendError, Result};
8use crate::policy::Policy;
9use std::time::{Duration, Instant};
10use testcontainers::{core::ExecCommand, runners::SyncRunner, GenericImage, ImageExt};
11use tokio::io::AsyncReadExt;
12use futures_util::TryFutureExt;
13
14#[cfg(feature = "otel-traces")]
15use tracing::{info, warn, instrument};
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<(String, String)>, memory_limit: Option<u64>,
37 cpu_limit: Option<f64>,
39}
40
41impl TestcontainerBackend {
42 pub fn new(image: impl Into<String>) -> Result<Self> {
44 let image_str = image.into();
45
46 let (image_name, image_tag) = if let Some((name, tag)) = image_str.split_once(':') {
48 (name.to_string(), tag.to_string())
49 } else {
50 (image_str, "latest".to_string())
51 };
52
53 Ok(Self {
54 image_name,
55 image_tag,
56 policy: Policy::default(),
57 timeout: Duration::from_secs(30), startup_timeout: Duration::from_secs(10), env_vars: std::collections::HashMap::new(),
60 default_command: None,
61 volume_mounts: Vec::new(),
62 memory_limit: None,
63 cpu_limit: None,
64 })
65 }
66
67 pub fn with_policy(mut self, policy: Policy) -> Self {
69 self.policy = policy;
70 self
71 }
72
73 pub fn with_timeout(mut self, timeout: Duration) -> Self {
75 self.timeout = timeout;
76 self
77 }
78
79 pub fn with_startup_timeout(mut self, timeout: Duration) -> Self {
81 self.startup_timeout = timeout;
82 self
83 }
84
85 pub fn is_running(&self) -> bool {
87 true
90 }
91
92 pub fn with_env(mut self, key: &str, val: &str) -> Self {
94 self.env_vars.insert(key.to_string(), val.to_string());
95 self
96 }
97
98 pub fn with_cmd(mut self, cmd: Vec<String>) -> Self {
100 self.default_command = Some(cmd);
101 self
102 }
103
104 pub fn with_volume(mut self, host_path: &str, container_path: &str) -> Self {
106 self.volume_mounts
107 .push((host_path.to_string(), container_path.to_string()));
108 self
109 }
110
111 pub fn with_memory_limit(mut self, limit_mb: u64) -> Self {
113 self.memory_limit = Some(limit_mb);
114 self
115 }
116
117 pub fn with_cpu_limit(mut self, cpus: f64) -> Self {
119 self.cpu_limit = Some(cpus);
120 self
121 }
122
123 pub fn is_available() -> bool {
125 true
127 }
128
129
130 #[cfg_attr(feature = "otel-traces", instrument(name = "testcontainer.execute", skip(self, cmd), fields(image = %self.image_name, tag = %self.image_tag)))]
132 fn execute_in_container(&self, cmd: &Cmd) -> Result<RunResult> {
133 let start_time = Instant::now();
134
135 #[cfg(feature = "otel-traces")]
136 info!("Starting container with image {}:{}", self.image_name, self.image_tag);
137
138 let image = GenericImage::new(self.image_name.clone(), self.image_tag.clone());
142
143 let mut container_request: testcontainers::core::ContainerRequest<
145 testcontainers::GenericImage,
146 > = image.into();
147
148 for (key, value) in &self.env_vars {
150 container_request = container_request.with_env_var(key, value);
151 }
152
153 for (key, value) in &cmd.env {
155 container_request = container_request.with_env_var(key, value);
156 }
157
158 for (key, value) in self.policy.to_env() {
160 container_request = container_request.with_env_var(key, value);
161 }
162
163 for (host_path, _container_path) in &self.volume_mounts {
165 }
168
169 container_request = container_request.with_cmd(vec!["sleep", "3600"]);
172
173 if let Some(workdir) = &cmd.workdir {
175 container_request =
176 container_request.with_working_dir(workdir.to_string_lossy().to_string());
177 }
178
179 let container_start_time = Instant::now();
181 let container = container_request
182 .start()
183 .map_err(|e| {
184 let elapsed = container_start_time.elapsed();
185 if elapsed > Duration::from_secs(10) {
186 #[cfg(feature = "otel-traces")]
187 warn!("Container startup took {}s, which is longer than expected. First pull of image may take time.", elapsed.as_secs());
188 }
189
190 BackendError::Runtime(format!(
191 "Failed to start container with image '{}:{}' after {}s.\n\
192 Possible causes:\n\
193 - Docker daemon not running (try: docker ps)\n\
194 - Image needs to be pulled (first run may take longer)\n\
195 - Network issues preventing image pull\n\
196 Try: Increase startup timeout or check Docker status\n\
197 Original error: {}",
198 self.image_name, self.image_tag, elapsed.as_secs(), e
199 ))
200 })?;
201
202 #[cfg(feature = "otel-traces")]
203 info!("Container started successfully, executing command");
204
205 let cmd_args: Vec<&str> = std::iter::once(cmd.bin.as_str())
207 .chain(cmd.args.iter().map(|s| s.as_str()))
208 .collect();
209
210 let exec_cmd = ExecCommand::new(cmd_args);
211 let mut exec_result = container
212 .exec(exec_cmd)
213 .map_err(|e| BackendError::Runtime(format!("Command execution failed: {}", e)))?;
214
215 let duration_ms = start_time.elapsed().as_millis() as u64;
216
217 #[cfg(feature = "otel-traces")]
218 info!("Command completed in {}ms", duration_ms);
219
220 use std::io::Read;
222 let mut stdout = String::new();
223 let mut stderr = String::new();
224
225 exec_result
226 .stdout()
227 .read_to_string(&mut stdout)
228 .map_err(|e| BackendError::Runtime(format!("Failed to read stdout: {}", e)))?;
229 exec_result
230 .stderr()
231 .read_to_string(&mut stderr)
232 .map_err(|e| BackendError::Runtime(format!("Failed to read stderr: {}", e)))?;
233
234 let exit_code = exec_result.exit_code().unwrap_or(Some(-1)).unwrap_or(-1) as i32;
235
236 Ok(RunResult {
237 exit_code,
238 stdout,
239 stderr,
240 duration_ms,
241 steps: Vec::new(),
242 redacted_env: Vec::new(),
243 backend: "testcontainers".to_string(),
244 concurrent: false,
245 step_order: Vec::new(),
246 })
247 }
248}
249
250impl Backend for TestcontainerBackend {
251 fn run_cmd(&self, cmd: Cmd) -> Result<RunResult> {
252 let start_time = Instant::now();
254
255 let result = self.execute_in_container(&cmd)?;
257
258 if start_time.elapsed() > self.timeout {
260 return Err(crate::error::CleanroomError::timeout_error(format!(
261 "Command execution timed out after {} seconds",
262 self.timeout.as_secs()
263 )));
264 }
265
266 Ok(result)
267 }
268
269 fn name(&self) -> &str {
270 "testcontainers"
271 }
272
273 fn is_available(&self) -> bool {
274 Self::is_available()
275 }
276
277 fn supports_hermetic(&self) -> bool {
278 true
279 }
280
281 fn supports_deterministic(&self) -> bool {
282 true
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn test_testcontainer_backend_creation() {
292 let backend = TestcontainerBackend::new("alpine:latest");
293 assert!(backend.is_ok());
294 }
295
296 #[test]
297 fn test_testcontainer_backend_with_timeout() {
298 let timeout = Duration::from_secs(60);
299 let backend = TestcontainerBackend::new("alpine:latest").unwrap();
300 let backend = backend.with_timeout(timeout);
301 assert!(backend.is_running());
302 }
303
304 #[test]
305 fn test_testcontainer_backend_trait() {
306 let backend = TestcontainerBackend::new("alpine:latest").unwrap();
307 assert!(backend.is_running());
308 }
309
310 #[test]
311 fn test_testcontainer_backend_image() {
312 let backend = TestcontainerBackend::new("ubuntu:20.04").unwrap();
313 assert!(backend.is_running());
314 }
315}