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