1use std::collections::HashMap;
6use std::process::{Command, Stdio};
7use thiserror::Error;
8
9#[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#[derive(Debug, Clone)]
30pub struct ContainerConfig {
31 pub image: String,
33
34 pub tag: String,
36
37 pub name: Option<String>,
39
40 pub env: HashMap<String, String>,
42
43 pub ports: HashMap<u16, u16>,
45
46 pub wait_timeout_secs: u64,
48}
49
50impl ContainerConfig {
51 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 pub fn with_name(mut self, name: impl Into<String>) -> Self {
73 self.name = Some(name.into());
74 self
75 }
76
77 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 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 pub fn with_wait_timeout(mut self, seconds: u64) -> Self {
91 self.wait_timeout_secs = seconds;
92 self
93 }
94
95 pub fn image_name(&self) -> String {
97 format!("{}:{}", self.image, self.tag)
98 }
99}
100
101pub struct DockerContainer {
103 config: ContainerConfig,
104 container_id: Option<String>,
105}
106
107impl DockerContainer {
108 pub fn new(config: ContainerConfig) -> Self {
122 Self {
123 config,
124 container_id: None,
125 }
126 }
127
128 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 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 self.pull_image()?;
149
150 let mut cmd = Command::new("docker");
152 cmd.arg("run")
153 .arg("-d") .arg("--rm"); if let Some(ref name) = self.config.name {
158 cmd.arg("--name").arg(name);
159 }
160
161 for (key, value) in &self.config.env {
163 cmd.arg("-e").arg(format!("{}={}", key, value));
164 }
165
166 for (host_port, container_port) in &self.config.ports {
168 cmd.arg("-p")
169 .arg(format!("{}:{}", host_port, container_port));
170 }
171
172 cmd.arg(self.config.image_name());
174
175 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 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 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 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 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 pub fn container_id(&self) -> Option<&str> {
270 self.container_id.as_deref()
271 }
272
273 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 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
311pub struct PostgresContainer;
313
314impl PostgresContainer {
315 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
335pub struct RedisContainer;
337
338impl RedisContainer {
339 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
356pub struct MongoContainer;
358
359impl MongoContainer {
360 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 let _ = DockerContainer::is_docker_available();
409 }
410
411 #[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 #[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 assert!(start.elapsed() < std::time::Duration::from_secs(1));
449 }
450
451 #[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 drop(container);
469 }
470
471 #[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 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}