1#![allow(clippy::doc_markdown)]
7#![allow(clippy::must_use_candidate)]
8#![allow(clippy::return_self_not_must_use)]
9#![allow(clippy::needless_borrows_for_generic_args)]
10#![allow(clippy::redundant_closure_for_method_calls)]
11#![allow(clippy::inefficient_to_string)]
12
13use crate::tracing_compat::{debug, error, info, trace, warn};
14use crate::{DockerCommand, RunCommand};
15use async_trait::async_trait;
16use std::collections::HashMap;
17
18#[cfg(any(
20 feature = "template-redis",
21 feature = "template-redis-cluster",
22 feature = "template-redis-enterprise"
23))]
24pub mod redis;
25
26#[cfg(any(
28 feature = "template-postgres",
29 feature = "template-mysql",
30 feature = "template-mongodb"
31))]
32pub mod database;
33
34#[cfg(feature = "template-nginx")]
36pub mod web;
37
38#[cfg(feature = "template-toxiproxy")]
40pub mod toxiproxy;
41
42pub type Result<T> = std::result::Result<T, TemplateError>;
44
45#[derive(Debug, thiserror::Error)]
47pub enum TemplateError {
48 #[error("Docker command failed: {0}")]
50 DockerError(#[from] crate::Error),
51
52 #[error("Invalid configuration: {0}")]
54 InvalidConfig(String),
55
56 #[error("Template already running: {0}")]
58 AlreadyRunning(String),
59
60 #[error("Template not running: {0}")]
62 NotRunning(String),
63
64 #[error("Timeout: {0}")]
66 Timeout(String),
67}
68
69#[derive(Debug, Clone)]
71pub struct TemplateConfig {
72 pub name: String,
74
75 pub image: String,
77
78 pub tag: String,
80
81 pub ports: Vec<(u16, u16)>,
83
84 pub env: HashMap<String, String>,
86
87 pub volumes: Vec<VolumeMount>,
89
90 pub network: Option<String>,
92
93 pub health_check: Option<HealthCheck>,
95
96 pub auto_remove: bool,
98
99 pub memory_limit: Option<String>,
101
102 pub cpu_limit: Option<String>,
104
105 pub platform: Option<String>,
107}
108
109#[derive(Debug, Clone)]
111pub struct VolumeMount {
112 pub source: String,
114
115 pub target: String,
117
118 pub read_only: bool,
120}
121
122#[derive(Debug, Clone)]
124pub struct HealthCheck {
125 pub test: Vec<String>,
127
128 pub interval: String,
130
131 pub timeout: String,
133
134 pub retries: i32,
136
137 pub start_period: String,
139}
140
141#[async_trait]
143pub trait Template: Send + Sync {
144 fn name(&self) -> &str;
146
147 fn config(&self) -> &TemplateConfig;
149
150 fn config_mut(&mut self) -> &mut TemplateConfig;
152
153 fn build_command(&self) -> RunCommand {
155 let config = self.config();
156 let mut cmd = RunCommand::new(format!("{}:{}", config.image, config.tag))
157 .name(&config.name)
158 .detach();
159
160 for (host, container) in &config.ports {
162 cmd = cmd.port(*host, *container);
163 }
164
165 for (key, value) in &config.env {
167 cmd = cmd.env(key, value);
168 }
169
170 for mount in &config.volumes {
172 if mount.read_only {
173 cmd = cmd.volume_ro(&mount.source, &mount.target);
174 } else {
175 cmd = cmd.volume(&mount.source, &mount.target);
176 }
177 }
178
179 if let Some(network) = &config.network {
181 cmd = cmd.network(network);
182 }
183
184 if let Some(health) = &config.health_check {
186 cmd = cmd
187 .health_cmd(&health.test.join(" "))
188 .health_interval(&health.interval)
189 .health_timeout(&health.timeout)
190 .health_retries(health.retries)
191 .health_start_period(&health.start_period);
192 }
193
194 if let Some(memory) = &config.memory_limit {
196 cmd = cmd.memory(memory);
197 }
198
199 if let Some(cpu) = &config.cpu_limit {
200 cmd = cmd.cpus(cpu);
201 }
202
203 if let Some(platform) = &config.platform {
205 cmd = cmd.platform(platform);
206 }
207
208 if config.auto_remove {
210 cmd = cmd.remove();
211 }
212
213 cmd
214 }
215
216 async fn start(&self) -> Result<String> {
218 let config = self.config();
219 info!(
220 template = %config.name,
221 image = %config.image,
222 tag = %config.tag,
223 "starting container from template"
224 );
225
226 let output = self.build_command().execute().await.map_err(|e| {
227 error!(
228 template = %config.name,
229 error = %e,
230 "failed to start container"
231 );
232 e
233 })?;
234
235 info!(
236 template = %config.name,
237 container_id = %output.0,
238 "container started successfully"
239 );
240
241 Ok(output.0)
242 }
243
244 async fn start_and_wait(&self) -> Result<String> {
246 let config = self.config();
247 info!(
248 template = %config.name,
249 "starting container and waiting for ready"
250 );
251
252 let container_id = self.start().await?;
253 self.wait_for_ready().await?;
254
255 info!(
256 template = %config.name,
257 container_id = %container_id,
258 "container started and ready"
259 );
260
261 Ok(container_id)
262 }
263
264 async fn stop(&self) -> Result<()> {
266 use crate::StopCommand;
267
268 let name = self.config().name.as_str();
269 info!(template = %name, "stopping container");
270
271 StopCommand::new(name).execute().await.map_err(|e| {
272 error!(template = %name, error = %e, "failed to stop container");
273 e
274 })?;
275
276 debug!(template = %name, "container stopped");
277 Ok(())
278 }
279
280 async fn remove(&self) -> Result<()> {
282 use crate::RmCommand;
283
284 let name = self.config().name.as_str();
285 info!(template = %name, "removing container");
286
287 RmCommand::new(name)
288 .force()
289 .volumes()
290 .execute()
291 .await
292 .map_err(|e| {
293 error!(template = %name, error = %e, "failed to remove container");
294 e
295 })?;
296
297 debug!(template = %name, "container removed");
298 Ok(())
299 }
300
301 async fn is_running(&self) -> Result<bool> {
303 use crate::PsCommand;
304
305 let name = &self.config().name;
306
307 let output = PsCommand::new()
308 .filter(format!("name={name}"))
309 .quiet()
310 .execute()
311 .await?;
312
313 let running = !output.stdout.trim().is_empty();
315 trace!(template = %name, running = running, "checked container running status");
316
317 Ok(running)
318 }
319
320 async fn logs(&self, follow: bool, tail: Option<&str>) -> Result<crate::CommandOutput> {
322 use crate::LogsCommand;
323
324 let mut cmd = LogsCommand::new(&self.config().name);
325
326 if follow {
327 cmd = cmd.follow();
328 }
329
330 if let Some(lines) = tail {
331 cmd = cmd.tail(lines);
332 }
333
334 cmd.execute().await.map_err(Into::into)
335 }
336
337 async fn exec(&self, command: Vec<&str>) -> Result<crate::ExecOutput> {
339 use crate::ExecCommand;
340
341 let cmd_vec: Vec<String> = command.iter().map(|s| s.to_string()).collect();
342 let cmd = ExecCommand::new(&self.config().name, cmd_vec);
343
344 cmd.execute().await.map_err(Into::into)
345 }
346
347 #[allow(clippy::too_many_lines)]
355 async fn wait_for_ready(&self) -> Result<()> {
356 use std::time::Duration;
357 use tokio::time::{sleep, timeout, Instant};
358
359 let name = &self.config().name;
360 let has_health_check = self.config().health_check.is_some();
361
362 let wait_timeout = Duration::from_secs(60);
364 let check_interval = Duration::from_millis(500);
365
366 info!(
367 template = %name,
368 timeout_secs = wait_timeout.as_secs(),
369 has_health_check = has_health_check,
370 "waiting for container to be ready"
371 );
372
373 let start_time = Instant::now();
374 let mut check_count = 0u32;
375
376 let result = timeout(wait_timeout, async {
377 loop {
378 check_count += 1;
379
380 let running = self.is_running().await.unwrap_or(false);
383 if !running {
384 trace!(
385 template = %name,
386 check = check_count,
387 "container not yet running, waiting"
388 );
389 sleep(check_interval).await;
390 continue;
391 }
392
393 if has_health_check {
395 use crate::InspectCommand;
396
397 if let Ok(inspect) = InspectCommand::new(name).execute().await {
398 if let Ok(containers) =
400 serde_json::from_str::<serde_json::Value>(&inspect.stdout)
401 {
402 if let Some(first) = containers.as_array().and_then(|arr| arr.first()) {
403 if let Some(state) = first.get("State") {
404 if let Some(health) = state.get("Health") {
405 if let Some(status) =
406 health.get("Status").and_then(|s| s.as_str())
407 {
408 trace!(
409 template = %name,
410 check = check_count,
411 health_status = %status,
412 "health check status"
413 );
414
415 if status == "healthy" {
416 #[allow(clippy::cast_possible_truncation)]
417 let elapsed_ms = start_time.elapsed().as_millis() as u64;
418 debug!(
419 template = %name,
420 checks = check_count,
421 elapsed_ms = elapsed_ms,
422 "container healthy"
423 );
424 return Ok(());
425 } else if status == "unhealthy" {
426 warn!(
427 template = %name,
428 "container reported unhealthy, continuing to wait"
429 );
430 }
431 }
432 } else if let Some(running) =
433 state.get("Running").and_then(|r| r.as_bool())
434 {
435 if running {
437 #[allow(clippy::cast_possible_truncation)]
438 let elapsed_ms = start_time.elapsed().as_millis() as u64;
439 debug!(
440 template = %name,
441 checks = check_count,
442 elapsed_ms = elapsed_ms,
443 "container running (no health check)"
444 );
445 return Ok(());
446 }
447 }
448 }
449 }
450 }
451 }
452 } else {
453 #[allow(clippy::cast_possible_truncation)]
455 let elapsed_ms = start_time.elapsed().as_millis() as u64;
456 debug!(
457 template = %name,
458 checks = check_count,
459 elapsed_ms = elapsed_ms,
460 "container running (no health check configured)"
461 );
462 return Ok(());
463 }
464
465 sleep(check_interval).await;
466 }
467 })
468 .await;
469
470 if let Ok(inner) = result {
471 inner
472 } else {
473 error!(
474 template = %name,
475 timeout_secs = wait_timeout.as_secs(),
476 checks = check_count,
477 "container failed to become ready within timeout"
478 );
479 Err(TemplateError::InvalidConfig(format!(
480 "Container {name} failed to become ready within timeout"
481 )))
482 }
483 }
484}
485
486pub struct TemplateBuilder {
488 config: TemplateConfig,
489}
490
491impl TemplateBuilder {
492 pub fn new(name: impl Into<String>, image: impl Into<String>) -> Self {
494 Self {
495 config: TemplateConfig {
496 name: name.into(),
497 image: image.into(),
498 tag: "latest".to_string(),
499 ports: Vec::new(),
500 env: HashMap::new(),
501 volumes: Vec::new(),
502 network: None,
503 health_check: None,
504 auto_remove: false,
505 memory_limit: None,
506 cpu_limit: None,
507 platform: None,
508 },
509 }
510 }
511
512 pub fn tag(mut self, tag: impl Into<String>) -> Self {
514 self.config.tag = tag.into();
515 self
516 }
517
518 pub fn port(mut self, host: u16, container: u16) -> Self {
520 self.config.ports.push((host, container));
521 self
522 }
523
524 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
526 self.config.env.insert(key.into(), value.into());
527 self
528 }
529
530 pub fn volume(mut self, source: impl Into<String>, target: impl Into<String>) -> Self {
532 self.config.volumes.push(VolumeMount {
533 source: source.into(),
534 target: target.into(),
535 read_only: false,
536 });
537 self
538 }
539
540 pub fn volume_ro(mut self, source: impl Into<String>, target: impl Into<String>) -> Self {
542 self.config.volumes.push(VolumeMount {
543 source: source.into(),
544 target: target.into(),
545 read_only: true,
546 });
547 self
548 }
549
550 pub fn network(mut self, network: impl Into<String>) -> Self {
552 self.config.network = Some(network.into());
553 self
554 }
555
556 pub fn auto_remove(mut self) -> Self {
558 self.config.auto_remove = true;
559 self
560 }
561
562 pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
564 self.config.memory_limit = Some(limit.into());
565 self
566 }
567
568 pub fn cpu_limit(mut self, limit: impl Into<String>) -> Self {
570 self.config.cpu_limit = Some(limit.into());
571 self
572 }
573
574 pub fn build(self) -> CustomTemplate {
576 CustomTemplate {
577 config: self.config,
578 }
579 }
580}
581
582pub struct CustomTemplate {
584 config: TemplateConfig,
585}
586
587#[async_trait]
588impl Template for CustomTemplate {
589 fn name(&self) -> &str {
590 &self.config.name
591 }
592
593 fn config(&self) -> &TemplateConfig {
594 &self.config
595 }
596
597 fn config_mut(&mut self) -> &mut TemplateConfig {
598 &mut self.config
599 }
600}
601
602pub trait HasConnectionString {
607 fn connection_string(&self) -> String;
615}
616
617#[cfg(feature = "template-redis")]
620pub use redis::RedisTemplate;
621
622#[cfg(feature = "template-redis-cluster")]
623pub use redis::{ClusterInfo, NodeInfo, NodeRole, RedisClusterConnection, RedisClusterTemplate};
624
625#[cfg(feature = "template-redis-enterprise")]
626pub use redis::{RedisEnterpriseConnectionInfo, RedisEnterpriseTemplate};
627
628#[cfg(feature = "template-postgres")]
629pub use database::postgres::{PostgresConnectionString, PostgresTemplate};
630
631#[cfg(feature = "template-mysql")]
632pub use database::mysql::{MysqlConnectionString, MysqlTemplate};
633
634#[cfg(feature = "template-mongodb")]
635pub use database::mongodb::{MongodbConnectionString, MongodbTemplate};
636
637#[cfg(feature = "template-nginx")]
638pub use web::nginx::NginxTemplate;
639
640#[cfg(feature = "template-toxiproxy")]
641pub use toxiproxy::{ProxyInfo, Toxic, ToxicStream, ToxiproxyTemplate};