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::{DockerCommand, RunCommand};
14use async_trait::async_trait;
15use std::collections::HashMap;
16use tracing::{debug, error, info, trace, warn};
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
38pub type Result<T> = std::result::Result<T, TemplateError>;
40
41#[derive(Debug, thiserror::Error)]
43pub enum TemplateError {
44 #[error("Docker command failed: {0}")]
46 DockerError(#[from] crate::Error),
47
48 #[error("Invalid configuration: {0}")]
50 InvalidConfig(String),
51
52 #[error("Template already running: {0}")]
54 AlreadyRunning(String),
55
56 #[error("Template not running: {0}")]
58 NotRunning(String),
59
60 #[error("Timeout: {0}")]
62 Timeout(String),
63}
64
65#[derive(Debug, Clone)]
67pub struct TemplateConfig {
68 pub name: String,
70
71 pub image: String,
73
74 pub tag: String,
76
77 pub ports: Vec<(u16, u16)>,
79
80 pub env: HashMap<String, String>,
82
83 pub volumes: Vec<VolumeMount>,
85
86 pub network: Option<String>,
88
89 pub health_check: Option<HealthCheck>,
91
92 pub auto_remove: bool,
94
95 pub memory_limit: Option<String>,
97
98 pub cpu_limit: Option<String>,
100
101 pub platform: Option<String>,
103}
104
105#[derive(Debug, Clone)]
107pub struct VolumeMount {
108 pub source: String,
110
111 pub target: String,
113
114 pub read_only: bool,
116}
117
118#[derive(Debug, Clone)]
120pub struct HealthCheck {
121 pub test: Vec<String>,
123
124 pub interval: String,
126
127 pub timeout: String,
129
130 pub retries: i32,
132
133 pub start_period: String,
135}
136
137#[async_trait]
139pub trait Template: Send + Sync {
140 fn name(&self) -> &str;
142
143 fn config(&self) -> &TemplateConfig;
145
146 fn config_mut(&mut self) -> &mut TemplateConfig;
148
149 fn build_command(&self) -> RunCommand {
151 let config = self.config();
152 let mut cmd = RunCommand::new(format!("{}:{}", config.image, config.tag))
153 .name(&config.name)
154 .detach();
155
156 for (host, container) in &config.ports {
158 cmd = cmd.port(*host, *container);
159 }
160
161 for (key, value) in &config.env {
163 cmd = cmd.env(key, value);
164 }
165
166 for mount in &config.volumes {
168 if mount.read_only {
169 cmd = cmd.volume_ro(&mount.source, &mount.target);
170 } else {
171 cmd = cmd.volume(&mount.source, &mount.target);
172 }
173 }
174
175 if let Some(network) = &config.network {
177 cmd = cmd.network(network);
178 }
179
180 if let Some(health) = &config.health_check {
182 cmd = cmd
183 .health_cmd(&health.test.join(" "))
184 .health_interval(&health.interval)
185 .health_timeout(&health.timeout)
186 .health_retries(health.retries)
187 .health_start_period(&health.start_period);
188 }
189
190 if let Some(memory) = &config.memory_limit {
192 cmd = cmd.memory(memory);
193 }
194
195 if let Some(cpu) = &config.cpu_limit {
196 cmd = cmd.cpus(cpu);
197 }
198
199 if let Some(platform) = &config.platform {
201 cmd = cmd.platform(platform);
202 }
203
204 if config.auto_remove {
206 cmd = cmd.remove();
207 }
208
209 cmd
210 }
211
212 async fn start(&self) -> Result<String> {
214 let config = self.config();
215 info!(
216 template = %config.name,
217 image = %config.image,
218 tag = %config.tag,
219 "starting container from template"
220 );
221
222 let output = self.build_command().execute().await.map_err(|e| {
223 error!(
224 template = %config.name,
225 error = %e,
226 "failed to start container"
227 );
228 e
229 })?;
230
231 info!(
232 template = %config.name,
233 container_id = %output.0,
234 "container started successfully"
235 );
236
237 Ok(output.0)
238 }
239
240 async fn start_and_wait(&self) -> Result<String> {
242 let config = self.config();
243 info!(
244 template = %config.name,
245 "starting container and waiting for ready"
246 );
247
248 let container_id = self.start().await?;
249 self.wait_for_ready().await?;
250
251 info!(
252 template = %config.name,
253 container_id = %container_id,
254 "container started and ready"
255 );
256
257 Ok(container_id)
258 }
259
260 async fn stop(&self) -> Result<()> {
262 use crate::StopCommand;
263
264 let name = self.config().name.as_str();
265 info!(template = %name, "stopping container");
266
267 StopCommand::new(name).execute().await.map_err(|e| {
268 error!(template = %name, error = %e, "failed to stop container");
269 e
270 })?;
271
272 debug!(template = %name, "container stopped");
273 Ok(())
274 }
275
276 async fn remove(&self) -> Result<()> {
278 use crate::RmCommand;
279
280 let name = self.config().name.as_str();
281 info!(template = %name, "removing container");
282
283 RmCommand::new(name)
284 .force()
285 .volumes()
286 .execute()
287 .await
288 .map_err(|e| {
289 error!(template = %name, error = %e, "failed to remove container");
290 e
291 })?;
292
293 debug!(template = %name, "container removed");
294 Ok(())
295 }
296
297 async fn is_running(&self) -> Result<bool> {
299 use crate::PsCommand;
300
301 let name = &self.config().name;
302
303 let output = PsCommand::new()
304 .filter(format!("name={name}"))
305 .quiet()
306 .execute()
307 .await?;
308
309 let running = !output.stdout.trim().is_empty();
311 trace!(template = %name, running = running, "checked container running status");
312
313 Ok(running)
314 }
315
316 async fn logs(&self, follow: bool, tail: Option<&str>) -> Result<crate::CommandOutput> {
318 use crate::LogsCommand;
319
320 let mut cmd = LogsCommand::new(&self.config().name);
321
322 if follow {
323 cmd = cmd.follow();
324 }
325
326 if let Some(lines) = tail {
327 cmd = cmd.tail(lines);
328 }
329
330 cmd.execute().await.map_err(Into::into)
331 }
332
333 async fn exec(&self, command: Vec<&str>) -> Result<crate::ExecOutput> {
335 use crate::ExecCommand;
336
337 let cmd_vec: Vec<String> = command.iter().map(|s| s.to_string()).collect();
338 let cmd = ExecCommand::new(&self.config().name, cmd_vec);
339
340 cmd.execute().await.map_err(Into::into)
341 }
342
343 #[allow(clippy::too_many_lines)]
351 async fn wait_for_ready(&self) -> Result<()> {
352 use std::time::Duration;
353 use tokio::time::{sleep, timeout, Instant};
354
355 let name = &self.config().name;
356 let has_health_check = self.config().health_check.is_some();
357
358 let wait_timeout = Duration::from_secs(60);
360 let check_interval = Duration::from_millis(500);
361
362 info!(
363 template = %name,
364 timeout_secs = wait_timeout.as_secs(),
365 has_health_check = has_health_check,
366 "waiting for container to be ready"
367 );
368
369 let start_time = Instant::now();
370 let mut check_count = 0u32;
371
372 let result = timeout(wait_timeout, async {
373 loop {
374 check_count += 1;
375
376 let running = self.is_running().await.unwrap_or(false);
379 if !running {
380 trace!(
381 template = %name,
382 check = check_count,
383 "container not yet running, waiting"
384 );
385 sleep(check_interval).await;
386 continue;
387 }
388
389 if has_health_check {
391 use crate::InspectCommand;
392
393 if let Ok(inspect) = InspectCommand::new(name).execute().await {
394 if let Ok(containers) =
396 serde_json::from_str::<serde_json::Value>(&inspect.stdout)
397 {
398 if let Some(first) = containers.as_array().and_then(|arr| arr.first()) {
399 if let Some(state) = first.get("State") {
400 if let Some(health) = state.get("Health") {
401 if let Some(status) =
402 health.get("Status").and_then(|s| s.as_str())
403 {
404 trace!(
405 template = %name,
406 check = check_count,
407 health_status = %status,
408 "health check status"
409 );
410
411 if status == "healthy" {
412 #[allow(clippy::cast_possible_truncation)]
413 let elapsed_ms = start_time.elapsed().as_millis() as u64;
414 debug!(
415 template = %name,
416 checks = check_count,
417 elapsed_ms = elapsed_ms,
418 "container healthy"
419 );
420 return Ok(());
421 } else if status == "unhealthy" {
422 warn!(
423 template = %name,
424 "container reported unhealthy, continuing to wait"
425 );
426 }
427 }
428 } else if let Some(running) =
429 state.get("Running").and_then(|r| r.as_bool())
430 {
431 if running {
433 #[allow(clippy::cast_possible_truncation)]
434 let elapsed_ms = start_time.elapsed().as_millis() as u64;
435 debug!(
436 template = %name,
437 checks = check_count,
438 elapsed_ms = elapsed_ms,
439 "container running (no health check)"
440 );
441 return Ok(());
442 }
443 }
444 }
445 }
446 }
447 }
448 } else {
449 #[allow(clippy::cast_possible_truncation)]
451 let elapsed_ms = start_time.elapsed().as_millis() as u64;
452 debug!(
453 template = %name,
454 checks = check_count,
455 elapsed_ms = elapsed_ms,
456 "container running (no health check configured)"
457 );
458 return Ok(());
459 }
460
461 sleep(check_interval).await;
462 }
463 })
464 .await;
465
466 if let Ok(inner) = result {
467 inner
468 } else {
469 error!(
470 template = %name,
471 timeout_secs = wait_timeout.as_secs(),
472 checks = check_count,
473 "container failed to become ready within timeout"
474 );
475 Err(TemplateError::InvalidConfig(format!(
476 "Container {name} failed to become ready within timeout"
477 )))
478 }
479 }
480}
481
482pub struct TemplateBuilder {
484 config: TemplateConfig,
485}
486
487impl TemplateBuilder {
488 pub fn new(name: impl Into<String>, image: impl Into<String>) -> Self {
490 Self {
491 config: TemplateConfig {
492 name: name.into(),
493 image: image.into(),
494 tag: "latest".to_string(),
495 ports: Vec::new(),
496 env: HashMap::new(),
497 volumes: Vec::new(),
498 network: None,
499 health_check: None,
500 auto_remove: false,
501 memory_limit: None,
502 cpu_limit: None,
503 platform: None,
504 },
505 }
506 }
507
508 pub fn tag(mut self, tag: impl Into<String>) -> Self {
510 self.config.tag = tag.into();
511 self
512 }
513
514 pub fn port(mut self, host: u16, container: u16) -> Self {
516 self.config.ports.push((host, container));
517 self
518 }
519
520 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
522 self.config.env.insert(key.into(), value.into());
523 self
524 }
525
526 pub fn volume(mut self, source: impl Into<String>, target: impl Into<String>) -> Self {
528 self.config.volumes.push(VolumeMount {
529 source: source.into(),
530 target: target.into(),
531 read_only: false,
532 });
533 self
534 }
535
536 pub fn volume_ro(mut self, source: impl Into<String>, target: impl Into<String>) -> Self {
538 self.config.volumes.push(VolumeMount {
539 source: source.into(),
540 target: target.into(),
541 read_only: true,
542 });
543 self
544 }
545
546 pub fn network(mut self, network: impl Into<String>) -> Self {
548 self.config.network = Some(network.into());
549 self
550 }
551
552 pub fn auto_remove(mut self) -> Self {
554 self.config.auto_remove = true;
555 self
556 }
557
558 pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
560 self.config.memory_limit = Some(limit.into());
561 self
562 }
563
564 pub fn cpu_limit(mut self, limit: impl Into<String>) -> Self {
566 self.config.cpu_limit = Some(limit.into());
567 self
568 }
569
570 pub fn build(self) -> CustomTemplate {
572 CustomTemplate {
573 config: self.config,
574 }
575 }
576}
577
578pub struct CustomTemplate {
580 config: TemplateConfig,
581}
582
583#[async_trait]
584impl Template for CustomTemplate {
585 fn name(&self) -> &str {
586 &self.config.name
587 }
588
589 fn config(&self) -> &TemplateConfig {
590 &self.config
591 }
592
593 fn config_mut(&mut self) -> &mut TemplateConfig {
594 &mut self.config
595 }
596}
597
598pub trait HasConnectionString {
603 fn connection_string(&self) -> String;
611}
612
613#[cfg(feature = "template-redis")]
616pub use redis::RedisTemplate;
617
618#[cfg(feature = "template-redis-cluster")]
619pub use redis::{ClusterInfo, NodeInfo, NodeRole, RedisClusterConnection, RedisClusterTemplate};
620
621#[cfg(feature = "template-redis-enterprise")]
622pub use redis::{RedisEnterpriseConnectionInfo, RedisEnterpriseTemplate};
623
624#[cfg(feature = "template-postgres")]
625pub use database::postgres::{PostgresConnectionString, PostgresTemplate};
626
627#[cfg(feature = "template-mysql")]
628pub use database::mysql::{MysqlConnectionString, MysqlTemplate};
629
630#[cfg(feature = "template-mongodb")]
631pub use database::mongodb::{MongodbConnectionString, MongodbTemplate};
632
633#[cfg(feature = "template-nginx")]
634pub use web::nginx::NginxTemplate;