clnrm_core/backend/
testcontainer.rs

1//! Testcontainers backend for containerized command execution
2//!
3//! Provides testcontainers-rs integration for hermetic, isolated execution
4//! with automatic container lifecycle management.
5
6use 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/// Testcontainers backend for containerized execution
18#[derive(Debug, Clone)]
19pub struct TestcontainerBackend {
20    /// Base image configuration
21    image_name: String,
22    image_tag: String,
23    /// Default policy
24    policy: Policy,
25    /// Command execution timeout
26    timeout: Duration,
27    /// Container startup timeout
28    startup_timeout: Duration,
29    /// Environment variables to set in container
30    env_vars: std::collections::HashMap<String, String>,
31    /// Default command to run in container
32    default_command: Option<Vec<String>>,
33    /// Volume mounts for the container
34    volume_mounts: Vec<(String, String)>, // (host_path, container_path)
35    /// Memory limit in MB
36    memory_limit: Option<u64>,
37    /// CPU limit (number of CPUs)
38    cpu_limit: Option<f64>,
39}
40
41impl TestcontainerBackend {
42    /// Create a new testcontainers backend
43    pub fn new(image: impl Into<String>) -> Result<Self> {
44        let image_str = image.into();
45        
46        // Parse image name and tag
47        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), // Reduced from 300s
58            startup_timeout: Duration::from_secs(10), // Reduced from 60s
59            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    /// Create with custom policy
68    pub fn with_policy(mut self, policy: Policy) -> Self {
69        self.policy = policy;
70        self
71    }
72
73    /// Create with custom execution timeout
74    pub fn with_timeout(mut self, timeout: Duration) -> Self {
75        self.timeout = timeout;
76        self
77    }
78
79    /// Create with custom startup timeout
80    pub fn with_startup_timeout(mut self, timeout: Duration) -> Self {
81        self.startup_timeout = timeout;
82        self
83    }
84
85    /// Check if the backend is running
86    pub fn is_running(&self) -> bool {
87        // For testcontainers, we consider the backend "running" if it can be created
88        // In a real implementation, this might check container status
89        true
90    }
91
92    /// Add environment variable to container
93    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    /// Set default command for container
99    pub fn with_cmd(mut self, cmd: Vec<String>) -> Self {
100        self.default_command = Some(cmd);
101        self
102    }
103
104    /// Add volume mount
105    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    /// Set memory limit in MB
112    pub fn with_memory_limit(mut self, limit_mb: u64) -> Self {
113        self.memory_limit = Some(limit_mb);
114        self
115    }
116
117    /// Set CPU limit (number of CPUs)
118    pub fn with_cpu_limit(mut self, cpus: f64) -> Self {
119        self.cpu_limit = Some(cpus);
120        self
121    }
122
123    /// Check if testcontainers is available
124    pub fn is_available() -> bool {
125        // For now, assume Docker is available if we can create a GenericImage
126        true
127    }
128
129
130    /// Execute command in container
131    #[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        // Docker availability will be checked by the container startup itself
139
140        // Create base image
141        let image = GenericImage::new(self.image_name.clone(), self.image_tag.clone());
142
143        // Build container request with all configurations
144        let mut container_request: testcontainers::core::ContainerRequest<
145            testcontainers::GenericImage,
146        > = image.into();
147
148        // Add environment variables from backend storage
149        for (key, value) in &self.env_vars {
150            container_request = container_request.with_env_var(key, value);
151        }
152
153        // Add environment variables from command
154        for (key, value) in &cmd.env {
155            container_request = container_request.with_env_var(key, value);
156        }
157
158        // Add policy environment variables
159        for (key, value) in self.policy.to_env() {
160            container_request = container_request.with_env_var(key, value);
161        }
162
163        // Add volume mounts from backend storage
164        for (host_path, _container_path) in &self.volume_mounts {
165            // TODO: Implement proper volume mounting with testcontainers API
166            // container_request = container_request.with_mapped_path(host_path, container_path);
167        }
168
169        // Set a default command to keep the container running
170        // Alpine containers exit immediately without a command
171        container_request = container_request.with_cmd(vec!["sleep", "3600"]);
172
173        // Set working directory if specified
174        if let Some(workdir) = &cmd.workdir {
175            container_request =
176                container_request.with_working_dir(workdir.to_string_lossy().to_string());
177        }
178
179        // Start container using SyncRunner with timeout monitoring
180        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        // Execute command - testcontainers expects Vec<&str> for exec
206        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        // Extract output - SyncExecResult provides stdout() and stderr() as streams
221        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        // Use synchronous execution with timeout
253        let start_time = Instant::now();
254
255        // Execute command with timeout
256        let result = self.execute_in_container(&cmd)?;
257
258        // Check if execution exceeded timeout
259        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}