docker_wrapper/template/redis/
basic.rs1#![allow(clippy::doc_markdown)]
4#![allow(clippy::must_use_candidate)]
5#![allow(clippy::return_self_not_must_use)]
6#![allow(clippy::needless_borrows_for_generic_args)]
7#![allow(clippy::unnecessary_get_then_check)]
8
9use super::common::{
10 default_redis_health_check, redis_config_volume, redis_connection_string, redis_data_volume,
11 redis_tls_connection_string, redis_tls_server_args, redis_tls_volume, DEFAULT_REDIS_IMAGE,
12 DEFAULT_REDIS_TAG, DEFAULT_REDIS_TLS_PORT, REDIS_STACK_IMAGE, REDIS_STACK_TAG,
13};
14use crate::template::{HasConnectionString, Template, TemplateConfig};
15use async_trait::async_trait;
16use std::collections::HashMap;
17
18pub struct RedisTemplate {
20 config: TemplateConfig,
21 use_redis_stack: bool,
22 stack_tag: String,
23 tls_certs_dir: Option<String>,
26 tls_port: u16,
28 tls_only: bool,
31}
32
33impl RedisTemplate {
34 pub fn new(name: impl Into<String>) -> Self {
36 let name = name.into();
37 let env = HashMap::new();
38
39 let config = TemplateConfig {
41 name: name.clone(),
42 image: DEFAULT_REDIS_IMAGE.to_string(),
43 tag: DEFAULT_REDIS_TAG.to_string(),
44 ports: vec![(6379, 6379)],
45 env,
46 volumes: Vec::new(),
47 network: None,
48 health_check: Some(default_redis_health_check()),
49 auto_remove: false,
50 memory_limit: None,
51 cpu_limit: None,
52 platform: None,
53 };
54
55 Self {
56 config,
57 use_redis_stack: false,
58 stack_tag: REDIS_STACK_TAG.to_string(),
59 tls_certs_dir: None,
60 tls_port: DEFAULT_REDIS_TLS_PORT,
61 tls_only: false,
62 }
63 }
64
65 pub fn port(mut self, port: u16) -> Self {
67 self.config.ports = vec![(port, 6379)];
68 self
69 }
70
71 pub fn password(mut self, password: impl Into<String>) -> Self {
73 self.config
75 .env
76 .insert("REDIS_PASSWORD".to_string(), password.into());
77 self
78 }
79
80 pub fn with_persistence(mut self, volume_name: impl Into<String>) -> Self {
82 self.config.volumes.push(redis_data_volume(volume_name));
83 self
84 }
85
86 pub fn config_file(mut self, config_path: impl Into<String>) -> Self {
88 self.config.volumes.push(redis_config_volume(config_path));
89 self
90 }
91
92 pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
94 self.config.memory_limit = Some(limit.into());
95 self
96 }
97
98 pub fn cluster_mode(mut self) -> Self {
100 self.config
101 .env
102 .insert("REDIS_CLUSTER".to_string(), "yes".to_string());
103 self
104 }
105
106 pub fn maxmemory_policy(mut self, policy: impl Into<String>) -> Self {
108 self.config
109 .env
110 .insert("REDIS_MAXMEMORY_POLICY".to_string(), policy.into());
111 self
112 }
113
114 pub fn version(mut self, version: impl Into<String>) -> Self {
116 self.config.tag = format!("{}-alpine", version.into());
117 self
118 }
119
120 pub fn network(mut self, network: impl Into<String>) -> Self {
122 self.config.network = Some(network.into());
123 self
124 }
125
126 pub fn network_mode(mut self, mode: impl Into<String>) -> Self {
132 self.config.network = Some(mode.into());
133 self
134 }
135
136 pub fn host_network(mut self) -> Self {
168 self.config.network = Some("host".to_string());
169 self
170 }
171
172 fn uses_host_network(&self) -> bool {
174 self.config.network.as_deref() == Some("host")
175 }
176
177 pub fn auto_remove(mut self) -> Self {
179 self.config.auto_remove = true;
180 self
181 }
182
183 pub fn with_redis_stack(mut self) -> Self {
189 self.use_redis_stack = true;
190 self
191 }
192
193 pub fn stack_version(mut self, tag: impl Into<String>) -> Self {
210 self.stack_tag = tag.into();
211 self
212 }
213
214 fn stack_image(&self) -> String {
216 format!("{REDIS_STACK_IMAGE}:{}", self.stack_tag)
217 }
218
219 pub fn custom_image(mut self, image: impl Into<String>, tag: impl Into<String>) -> Self {
221 self.config.image = image.into();
222 self.config.tag = tag.into();
223 self
224 }
225
226 pub fn platform(mut self, platform: impl Into<String>) -> Self {
228 self.config.platform = Some(platform.into());
229 self
230 }
231
232 pub fn tls(mut self, certs_dir: impl Into<String>) -> Self {
274 self.tls_certs_dir = Some(certs_dir.into());
275 self
276 }
277
278 pub fn tls_port(mut self, port: u16) -> Self {
282 self.tls_port = port;
283 self
284 }
285
286 pub fn tls_only(mut self) -> Self {
292 self.tls_only = true;
293 self
294 }
295
296 fn tls_enabled(&self) -> bool {
298 self.tls_certs_dir.is_some()
299 }
300
301 pub fn tls_connection_string(&self) -> Option<String> {
322 if !self.tls_enabled() {
323 return None;
324 }
325 let password = self.config.env.get("REDIS_PASSWORD").map(String::as_str);
326 Some(redis_tls_connection_string(
327 "localhost",
328 self.tls_port,
329 password,
330 ))
331 }
332}
333
334#[async_trait]
335impl Template for RedisTemplate {
336 fn name(&self) -> &str {
337 &self.config.name
338 }
339
340 fn config(&self) -> &TemplateConfig {
341 &self.config
342 }
343
344 fn config_mut(&mut self) -> &mut TemplateConfig {
345 &mut self.config
346 }
347
348 async fn wait_for_ready(&self) -> crate::template::Result<()> {
349 use std::time::Duration;
350 use tokio::time::{sleep, timeout};
351
352 let wait_timeout = Duration::from_secs(60);
355 let check_interval = Duration::from_millis(500);
356
357 timeout(wait_timeout, async {
358 loop {
359 if !self.is_running().await.unwrap_or(false) {
362 sleep(check_interval).await;
363 continue;
364 }
365
366 let password = self.config.env.get("REDIS_PASSWORD");
368 let mut ping_cmd = vec!["redis-cli", "-h", "localhost"];
369
370 let auth_args;
372 if let Some(pass) = password {
373 auth_args = vec!["-a", pass.as_str()];
374 ping_cmd.extend(&auth_args);
375 }
376
377 ping_cmd.push("ping");
378
379 if let Ok(result) = self.exec(ping_cmd).await {
381 if result.stdout.trim() == "PONG" {
382 return Ok(());
383 }
384 }
385
386 sleep(check_interval).await;
387 }
388 })
389 .await
390 .map_err(|_| {
391 crate::template::TemplateError::InvalidConfig(format!(
392 "Redis container {} failed to become ready within timeout",
393 self.config().name
394 ))
395 })?
396 }
397
398 fn build_command(&self) -> crate::RunCommand {
399 let config = self.config();
400
401 let image_tag = if self.use_redis_stack {
403 self.stack_image()
404 } else {
405 format!("{}:{}", config.image, config.tag)
406 };
407
408 let mut cmd = crate::RunCommand::new(image_tag)
409 .name(&config.name)
410 .detach();
411
412 if !self.uses_host_network() {
416 if !(self.tls_enabled() && self.tls_only) {
418 for (host, container) in &config.ports {
419 cmd = cmd.port(*host, *container);
420 }
421 }
422 if self.tls_enabled() {
424 cmd = cmd.port(self.tls_port, DEFAULT_REDIS_TLS_PORT);
425 }
426 }
427
428 for mount in &config.volumes {
430 if mount.read_only {
431 cmd = cmd.volume_ro(&mount.source, &mount.target);
432 } else {
433 cmd = cmd.volume(&mount.source, &mount.target);
434 }
435 }
436
437 if let Some(ref certs_dir) = self.tls_certs_dir {
439 let mount = redis_tls_volume(certs_dir.clone());
440 cmd = cmd.volume_ro(&mount.source, &mount.target);
441 }
442
443 if let Some(network) = &config.network {
445 cmd = cmd.network(network);
446 }
447
448 if let Some(health) = &config.health_check {
450 cmd = cmd
451 .health_cmd(&health.test.join(" "))
452 .health_interval(&health.interval)
453 .health_timeout(&health.timeout)
454 .health_retries(health.retries)
455 .health_start_period(&health.start_period);
456 }
457
458 if let Some(memory) = &config.memory_limit {
460 cmd = cmd.memory(memory);
461 }
462
463 if let Some(cpu) = &config.cpu_limit {
464 cmd = cmd.cpus(cpu);
465 }
466
467 if config.auto_remove {
469 cmd = cmd.remove();
470 }
471
472 let password = config.env.get("REDIS_PASSWORD");
476 if password.is_some() || self.tls_enabled() {
477 let mut server_flags: Vec<String> = Vec::new();
480 if let Some(password) = password {
481 server_flags.push("--requirepass".to_string());
482 server_flags.push(password.clone());
483 server_flags.push("--protected-mode".to_string());
484 server_flags.push("yes".to_string());
485 }
486 if self.tls_enabled() {
487 if self.tls_only {
488 server_flags.push("--port".to_string());
490 server_flags.push("0".to_string());
491 }
492 server_flags.extend(redis_tls_server_args(DEFAULT_REDIS_TLS_PORT));
493 }
494
495 if self.use_redis_stack {
496 cmd = cmd.env("REDIS_ARGS", server_flags.join(" "));
499 } else {
500 cmd = cmd.entrypoint("redis-server").cmd(server_flags);
503 }
504 }
505
506 let has_config = config
509 .volumes
510 .iter()
511 .any(|v| v.target == "/usr/local/etc/redis/redis.conf");
512 if has_config && password.is_none() && !self.tls_enabled() {
513 cmd = cmd.cmd(vec![
514 "redis-server".to_string(),
515 "/usr/local/etc/redis/redis.conf".to_string(),
516 ]);
517 }
518
519 cmd
520 }
521}
522
523impl HasConnectionString for RedisTemplate {
524 fn connection_string(&self) -> String {
553 if self.tls_enabled() && self.tls_only {
556 if let Some(tls) = self.tls_connection_string() {
557 return tls;
558 }
559 }
560 let port = self.config.ports.first().map_or(6379, |(h, _)| *h);
561 let password = self.config.env.get("REDIS_PASSWORD").map(String::as_str);
562 redis_connection_string("localhost", port, password)
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::DockerCommand;
570
571 #[test]
572 fn test_redis_template_basic() {
573 let template = RedisTemplate::new("test-redis");
574 assert_eq!(template.name(), "test-redis");
575 assert_eq!(template.config().image, "redis");
576 assert_eq!(template.config().tag, "7-alpine");
577 assert_eq!(template.config().ports, vec![(6379, 6379)]);
578 }
579
580 #[test]
581 fn test_redis_template_with_password() {
582 let template = RedisTemplate::new("test-redis").password("secret123");
583
584 assert_eq!(
585 template.config().env.get("REDIS_PASSWORD"),
586 Some(&"secret123".to_string())
587 );
588 }
589
590 #[test]
591 fn test_redis_template_with_persistence() {
592 let template = RedisTemplate::new("test-redis").with_persistence("redis-data");
593
594 assert_eq!(template.config().volumes.len(), 1);
595 assert_eq!(template.config().volumes[0].source, "redis-data");
596 assert_eq!(template.config().volumes[0].target, "/data");
597 }
598
599 #[test]
600 fn test_redis_template_custom_port() {
601 let template = RedisTemplate::new("test-redis").port(16379);
602
603 assert_eq!(template.config().ports, vec![(16379, 6379)]);
604 }
605
606 #[test]
607 fn test_redis_build_command() {
608 let template = RedisTemplate::new("test-redis")
609 .password("mypass")
610 .port(16379);
611
612 let cmd = template.build_command();
613 let args = cmd.build_command_args();
614
615 assert!(args.contains(&"run".to_string()));
617 assert!(args.contains(&"--name".to_string()));
618 assert!(args.contains(&"test-redis".to_string()));
619 assert!(args.contains(&"--publish".to_string()));
620 assert!(args.contains(&"16379:6379".to_string()));
621 }
622
623 #[test]
624 fn test_redis_host_network() {
625 let template = RedisTemplate::new("test-redis").host_network();
626 assert_eq!(template.config().network.as_deref(), Some("host"));
627
628 let cmd = template.build_command();
629 let args = cmd.build_command_args();
630
631 let network_pos = args.iter().position(|a| a == "--network").unwrap();
633 assert_eq!(args[network_pos + 1], "host");
634 assert!(!args.contains(&"--publish".to_string()));
635 }
636
637 #[test]
638 fn test_redis_network_mode_host() {
639 let template = RedisTemplate::new("test-redis").network_mode("host");
640 assert_eq!(template.config().network.as_deref(), Some("host"));
641
642 let cmd = template.build_command();
643 let args = cmd.build_command_args();
644 assert!(!args.contains(&"--publish".to_string()));
645 }
646
647 #[test]
648 fn test_redis_network_mode_named_still_publishes() {
649 let template = RedisTemplate::new("test-redis")
652 .port(16379)
653 .network_mode("my-net");
654
655 let cmd = template.build_command();
656 let args = cmd.build_command_args();
657 assert!(args.contains(&"--publish".to_string()));
658 assert!(args.contains(&"16379:6379".to_string()));
659 }
660
661 #[test]
662 fn test_redis_stack_default_pinned_tag() {
663 let template = RedisTemplate::new("test-redis").with_redis_stack();
666
667 assert_eq!(
668 template.stack_image(),
669 format!("{REDIS_STACK_IMAGE}:7.4.0-v3")
670 );
671
672 let cmd = template.build_command();
673 let args = cmd.build_command_args();
674 assert!(args.contains(&"redis/redis-stack:7.4.0-v3".to_string()));
675 assert!(!args.iter().any(|a| a == "redis/redis-stack:latest"));
676 }
677
678 #[test]
679 fn test_redis_stack_version_override() {
680 let template = RedisTemplate::new("test-redis")
681 .with_redis_stack()
682 .stack_version("7.2.0-v9");
683
684 assert_eq!(template.stack_image(), "redis/redis-stack:7.2.0-v9");
685
686 let cmd = template.build_command();
687 let args = cmd.build_command_args();
688 assert!(args.contains(&"redis/redis-stack:7.2.0-v9".to_string()));
689 }
690
691 #[test]
692 fn test_redis_stack_version_ignored_without_stack() {
693 let template = RedisTemplate::new("test-redis").stack_version("7.2.0-v9");
695
696 let cmd = template.build_command();
697 let args = cmd.build_command_args();
698 assert!(args.contains(&"redis:7-alpine".to_string()));
699 assert!(!args.iter().any(|a| a.starts_with("redis/redis-stack")));
700 }
701
702 #[test]
703 fn test_redis_connection_string() {
704 use crate::template::HasConnectionString;
705
706 let template = RedisTemplate::new("test-redis").port(6380);
707 assert_eq!(template.connection_string(), "redis://localhost:6380");
708 }
709
710 #[test]
711 fn test_redis_connection_string_with_password() {
712 use crate::template::HasConnectionString;
713
714 let template = RedisTemplate::new("test-redis")
715 .port(6380)
716 .password("secret");
717 assert_eq!(
718 template.connection_string(),
719 "redis://:secret@localhost:6380"
720 );
721 }
722
723 #[test]
724 fn test_redis_connection_string_default_port() {
725 use crate::template::HasConnectionString;
726
727 let template = RedisTemplate::new("test-redis");
728 assert_eq!(template.connection_string(), "redis://localhost:6379");
729 }
730
731 #[test]
732 fn test_redis_tls_args_and_volume() {
733 let template = RedisTemplate::new("test-redis").tls("/tmp/certs");
734
735 let cmd = template.build_command();
736 let args = cmd.build_command_args();
737
738 assert!(args.contains(&"--tls-port".to_string()));
740 assert!(args.contains(&"6380".to_string()));
741 assert!(args.contains(&"--tls-cert-file".to_string()));
742 assert!(args.contains(&"/tls/redis.crt".to_string()));
743 assert!(args.contains(&"--tls-key-file".to_string()));
744 assert!(args.contains(&"/tls/redis.key".to_string()));
745 assert!(args.contains(&"--tls-ca-cert-file".to_string()));
746 assert!(args.contains(&"/tls/ca.crt".to_string()));
747
748 assert!(args.contains(&"/tmp/certs:/tls:ro".to_string()));
750
751 assert!(args.contains(&"6380:6380".to_string()));
753 assert!(args.contains(&"6379:6379".to_string()));
754
755 assert!(!args.windows(2).any(|w| w == ["--port", "0"]));
757 }
758
759 #[test]
760 fn test_redis_tls_custom_port() {
761 let template = RedisTemplate::new("test-redis")
762 .tls("/tmp/certs")
763 .tls_port(7000);
764
765 let cmd = template.build_command();
766 let args = cmd.build_command_args();
767
768 assert!(args.contains(&"7000:6380".to_string()));
770 }
771
772 #[test]
773 fn test_redis_tls_only_disables_plaintext() {
774 let template = RedisTemplate::new("test-redis")
775 .tls("/tmp/certs")
776 .tls_only();
777
778 let cmd = template.build_command();
779 let args = cmd.build_command_args();
780
781 assert!(args.windows(2).any(|w| w == ["--port", "0"]));
783 assert!(!args.contains(&"6379:6379".to_string()));
784
785 assert!(args.contains(&"6380:6380".to_string()));
787 }
788
789 #[test]
790 fn test_redis_tls_with_password() {
791 let template = RedisTemplate::new("test-redis")
792 .tls("/tmp/certs")
793 .password("secret");
794
795 let cmd = template.build_command();
796 let args = cmd.build_command_args();
797
798 assert!(args.windows(2).any(|w| w == ["--requirepass", "secret"]));
800 assert!(args.contains(&"--tls-port".to_string()));
801 }
802
803 #[test]
804 fn test_redis_tls_connection_string() {
805 let template = RedisTemplate::new("test-redis").tls("/tmp/certs");
806 assert_eq!(
807 template.tls_connection_string().as_deref(),
808 Some("rediss://localhost:6380")
809 );
810
811 let with_pass = RedisTemplate::new("test-redis")
812 .tls("/tmp/certs")
813 .tls_port(7000)
814 .password("secret");
815 assert_eq!(
816 with_pass.tls_connection_string().as_deref(),
817 Some("rediss://:secret@localhost:7000")
818 );
819 }
820
821 #[test]
822 fn test_redis_tls_connection_string_none_without_tls() {
823 let template = RedisTemplate::new("test-redis");
824 assert_eq!(template.tls_connection_string(), None);
825 }
826
827 #[test]
828 fn test_redis_tls_only_connection_string_falls_back_to_tls() {
829 use crate::template::HasConnectionString;
830
831 let template = RedisTemplate::new("test-redis")
832 .tls("/tmp/certs")
833 .tls_only();
834 assert_eq!(template.connection_string(), "rediss://localhost:6380");
836 }
837}