1#![allow(clippy::doc_markdown)]
4#![allow(clippy::must_use_candidate)]
5#![allow(clippy::return_self_not_must_use)]
6#![allow(clippy::uninlined_format_args)]
7#![allow(clippy::cast_possible_truncation)]
8#![allow(clippy::missing_errors_doc)]
9#![allow(clippy::struct_excessive_bools)]
12
13use super::common::{
14 redis_tls_server_args, REDIS_INSIGHT_CLUSTER_IMAGE, REDIS_INSIGHT_TAG,
15 REDIS_STACK_SERVER_IMAGE, REDIS_STACK_TAG, REDIS_TLS_CA_FILE, REDIS_TLS_CERT_FILE,
16 REDIS_TLS_DIR, REDIS_TLS_KEY_FILE,
17};
18use crate::template::{Template, TemplateConfig, TemplateError};
19use crate::{DockerCommand, ExecCommand, NetworkCreateCommand, RunCommand};
20use async_trait::async_trait;
21
22pub struct RedisClusterTemplate {
24 name: String,
26 num_masters: usize,
28 num_replicas: usize,
30 port_base: u16,
32 network_name: String,
34 password: Option<String>,
36 announce_ip: Option<String>,
38 volume_prefix: Option<String>,
40 memory_limit: Option<String>,
42 node_timeout: u32,
44 host_network: bool,
46 auto_remove: bool,
48 use_redis_stack: bool,
50 stack_tag: String,
52 with_redis_insight: bool,
54 redis_insight_port: u16,
56 redis_insight_tag: String,
58 redis_image: Option<String>,
60 redis_tag: Option<String>,
62 platform: Option<String>,
64 tls_certs_dir: Option<String>,
67}
68
69impl RedisClusterTemplate {
70 pub fn new(name: impl Into<String>) -> Self {
72 let name = name.into();
73 let network_name = format!("{}-network", name);
74
75 Self {
76 name,
77 num_masters: 3,
78 num_replicas: 0,
79 port_base: 7000,
80 network_name,
81 password: None,
82 announce_ip: None,
83 volume_prefix: None,
84 memory_limit: None,
85 node_timeout: 5000,
86 host_network: false,
87 auto_remove: false,
88 use_redis_stack: false,
89 stack_tag: REDIS_STACK_TAG.to_string(),
90 with_redis_insight: false,
91 redis_insight_port: 8001,
92 redis_insight_tag: REDIS_INSIGHT_TAG.to_string(),
93 redis_image: None,
94 redis_tag: None,
95 platform: None,
96 tls_certs_dir: None,
97 }
98 }
99
100 pub fn from_env(name: impl Into<String>) -> Self {
120 let mut template = Self::new(name);
121
122 if let Ok(port_base) = std::env::var("REDIS_CLUSTER_PORT_BASE") {
123 if let Ok(port) = port_base.parse::<u16>() {
124 template.port_base = port;
125 }
126 }
127
128 if let Ok(num_masters) = std::env::var("REDIS_CLUSTER_NUM_MASTERS") {
129 if let Ok(masters) = num_masters.parse::<usize>() {
130 template.num_masters = masters.max(3);
131 }
132 }
133
134 if let Ok(num_replicas) = std::env::var("REDIS_CLUSTER_NUM_REPLICAS") {
135 if let Ok(replicas) = num_replicas.parse::<usize>() {
136 template.num_replicas = replicas;
137 }
138 }
139
140 if let Ok(password) = std::env::var("REDIS_CLUSTER_PASSWORD") {
141 template.password = Some(password);
142 }
143
144 template
145 }
146
147 pub fn get_port_base(&self) -> u16 {
149 self.port_base
150 }
151
152 pub fn get_num_masters(&self) -> usize {
154 self.num_masters
155 }
156
157 pub fn get_num_replicas(&self) -> usize {
159 self.num_replicas
160 }
161
162 pub fn num_masters(mut self, masters: usize) -> Self {
164 self.num_masters = masters.max(3);
165 self
166 }
167
168 pub fn num_replicas(mut self, replicas: usize) -> Self {
170 self.num_replicas = replicas;
171 self
172 }
173
174 pub fn port_base(mut self, port: u16) -> Self {
176 self.port_base = port;
177 self
178 }
179
180 pub fn password(mut self, password: impl Into<String>) -> Self {
182 self.password = Some(password.into());
183 self
184 }
185
186 pub fn cluster_announce_ip(mut self, ip: impl Into<String>) -> Self {
188 self.announce_ip = Some(ip.into());
189 self
190 }
191
192 pub fn with_persistence(mut self, volume_prefix: impl Into<String>) -> Self {
194 self.volume_prefix = Some(volume_prefix.into());
195 self
196 }
197
198 pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
200 self.memory_limit = Some(limit.into());
201 self
202 }
203
204 pub fn cluster_node_timeout(mut self, timeout: u32) -> Self {
206 self.node_timeout = timeout;
207 self
208 }
209
210 pub fn network_mode(mut self, mode: impl Into<String>) -> Self {
218 self.host_network = mode.into() == "host";
219 self
220 }
221
222 pub fn host_network(mut self) -> Self {
257 self.host_network = true;
258 self
259 }
260
261 fn uses_host_network(&self) -> bool {
263 self.host_network
264 }
265
266 fn node_internal_port(&self, index: usize) -> u16 {
272 if self.uses_host_network() {
273 self.port_base + index as u16
274 } else {
275 6379
276 }
277 }
278
279 fn node_cluster_address(&self, index: usize) -> String {
286 if self.uses_host_network() {
287 format!("127.0.0.1:{}", self.node_internal_port(index))
288 } else {
289 format!("{}:6379", self.node_name(index))
290 }
291 }
292
293 pub fn auto_remove(mut self) -> Self {
295 self.auto_remove = true;
296 self
297 }
298
299 pub fn with_redis_stack(mut self) -> Self {
306 self.use_redis_stack = true;
307 self
308 }
309
310 pub fn stack_version(mut self, tag: impl Into<String>) -> Self {
327 self.stack_tag = tag.into();
328 self
329 }
330
331 pub fn with_redis_insight(mut self) -> Self {
337 self.with_redis_insight = true;
338 self
339 }
340
341 pub fn redis_insight_port(mut self, port: u16) -> Self {
343 self.redis_insight_port = port;
344 self
345 }
346
347 pub fn redis_insight_version(mut self, tag: impl Into<String>) -> Self {
363 self.redis_insight_tag = tag.into();
364 self
365 }
366
367 pub fn custom_redis_image(mut self, image: impl Into<String>, tag: impl Into<String>) -> Self {
369 self.redis_image = Some(image.into());
370 self.redis_tag = Some(tag.into());
371 self
372 }
373
374 pub fn platform(mut self, platform: impl Into<String>) -> Self {
376 self.platform = Some(platform.into());
377 self
378 }
379
380 pub fn tls(mut self, certs_dir: impl Into<String>) -> Self {
410 self.tls_certs_dir = Some(certs_dir.into());
411 self
412 }
413
414 fn tls_enabled(&self) -> bool {
416 self.tls_certs_dir.is_some()
417 }
418
419 fn push_cli_tls_args(&self, args: &mut Vec<String>) {
423 if self.tls_enabled() {
424 args.push("--tls".to_string());
425 args.push("--cacert".to_string());
426 args.push(format!("{REDIS_TLS_DIR}/{REDIS_TLS_CA_FILE}"));
427 args.push("--cert".to_string());
428 args.push(format!("{REDIS_TLS_DIR}/{REDIS_TLS_CERT_FILE}"));
429 args.push("--key".to_string());
430 args.push(format!("{REDIS_TLS_DIR}/{REDIS_TLS_KEY_FILE}"));
431 }
432 }
433
434 fn total_nodes(&self) -> usize {
436 self.num_masters + (self.num_masters * self.num_replicas)
437 }
438
439 fn node_name(&self, index: usize) -> String {
446 format!("{}-node-{}", self.name, index)
447 }
448
449 pub fn node_names(&self) -> Vec<String> {
472 (0..self.total_nodes()).map(|i| self.node_name(i)).collect()
473 }
474
475 pub fn node(&self, index: usize) -> Option<ClusterNode> {
511 if index >= self.total_nodes() {
512 return None;
513 }
514
515 let role = if index < self.num_masters {
516 NodeRole::Master
517 } else {
518 NodeRole::Replica
519 };
520
521 Some(ClusterNode {
522 index,
523 container_name: self.node_name(index),
524 host_port: self.port_base + index as u16,
525 role,
526 })
527 }
528
529 pub async fn node_role(&self, index: usize) -> Result<NodeRole, TemplateError> {
559 if index >= self.total_nodes() {
560 return Err(TemplateError::InvalidConfig(format!(
561 "Node index {} out of range for cluster '{}' with {} nodes",
562 index,
563 self.name,
564 self.total_nodes()
565 )));
566 }
567
568 let node_name = self.node_name(index);
569
570 let mut role_args = vec!["redis-cli".to_string()];
571 if self.uses_host_network() {
572 role_args.push("-p".to_string());
573 role_args.push(self.node_internal_port(index).to_string());
574 }
575 self.push_cli_tls_args(&mut role_args);
576 if let Some(ref password) = self.password {
577 role_args.push("-a".to_string());
578 role_args.push(password.clone());
579 }
580 role_args.push("role".to_string());
581
582 let output = ExecCommand::new(&node_name, role_args).execute().await?;
583
584 match output.stdout.lines().next().map(str::trim) {
587 Some("master") => Ok(NodeRole::Master),
588 Some("slave") => Ok(NodeRole::Replica),
589 other => Err(TemplateError::InvalidConfig(format!(
590 "Unexpected role output for node '{}': {:?}",
591 node_name, other
592 ))),
593 }
594 }
595
596 async fn create_network(&self) -> Result<String, TemplateError> {
598 let output = NetworkCreateCommand::new(&self.network_name)
599 .driver("bridge")
600 .execute()
601 .await?;
602
603 Ok(output.stdout.trim().to_string())
605 }
606
607 async fn start_node(&self, node_index: usize) -> Result<String, TemplateError> {
609 let node_name = self.node_name(node_index);
610 let host_mode = self.uses_host_network();
611 let port = self.port_base + node_index as u16;
612 let internal_port = self.node_internal_port(node_index);
615 let cluster_port = port + 10000;
616
617 let image = self.node_image();
619
620 let mut cmd = RunCommand::new(image).name(&node_name).detach();
621
622 if host_mode {
623 cmd = cmd.network("host");
625 } else {
626 cmd = cmd
627 .network(&self.network_name)
628 .port(port, 6379)
629 .port(cluster_port, 16379);
630 }
631
632 if let Some(ref limit) = self.memory_limit {
634 cmd = cmd.memory(limit);
635 }
636
637 if let Some(ref prefix) = self.volume_prefix {
639 let volume_name = format!("{}-{}", prefix, node_index);
640 cmd = cmd.volume(&volume_name, "/data");
641 }
642
643 if let Some(ref certs_dir) = self.tls_certs_dir {
645 cmd = cmd.volume_ro(certs_dir, REDIS_TLS_DIR);
646 }
647
648 if let Some(ref platform) = self.platform {
650 cmd = cmd.platform(platform);
651 }
652
653 if self.auto_remove {
655 cmd = cmd.remove();
656 }
657
658 let mut redis_args = vec![
660 "redis-server".to_string(),
661 "--cluster-enabled".to_string(),
662 "yes".to_string(),
663 "--cluster-config-file".to_string(),
664 "nodes.conf".to_string(),
665 "--cluster-node-timeout".to_string(),
666 self.node_timeout.to_string(),
667 "--appendonly".to_string(),
668 "yes".to_string(),
669 ];
670
671 if self.tls_enabled() {
672 redis_args.push("--port".to_string());
676 redis_args.push("0".to_string());
677 redis_args.extend(redis_tls_server_args(internal_port));
678 redis_args.push("--tls-cluster".to_string());
679 redis_args.push("yes".to_string());
680 redis_args.push("--tls-replication".to_string());
681 redis_args.push("yes".to_string());
682 } else {
683 redis_args.push("--port".to_string());
684 redis_args.push(internal_port.to_string());
685 }
686
687 if let Some(ref password) = self.password {
689 redis_args.push("--requirepass".to_string());
690 redis_args.push(password.clone());
691 redis_args.push("--masterauth".to_string());
692 redis_args.push(password.clone());
693 }
694
695 if !host_mode {
699 if let Some(ref ip) = self.announce_ip {
700 redis_args.push("--cluster-announce-ip".to_string());
701 redis_args.push(ip.clone());
702 redis_args.push("--cluster-announce-port".to_string());
703 redis_args.push(port.to_string());
704 redis_args.push("--cluster-announce-bus-port".to_string());
705 redis_args.push(cluster_port.to_string());
706 }
707 }
708
709 cmd = cmd.cmd(redis_args);
710
711 let output = cmd.execute().await?;
712 Ok(output.0)
713 }
714
715 async fn start_redis_insight(&self) -> Result<String, TemplateError> {
717 let insight_name = format!("{}-insight", self.name);
718
719 let mut cmd = RunCommand::new(self.insight_image())
720 .name(&insight_name)
721 .detach();
722
723 if self.uses_host_network() {
724 cmd = cmd.network("host");
727 } else {
728 cmd = cmd
729 .network(&self.network_name)
730 .port(self.redis_insight_port, 8001);
731 }
732
733 if let Some(ref prefix) = self.volume_prefix {
735 let volume_name = format!("{}-insight", prefix);
736 cmd = cmd.volume(&volume_name, "/db");
737 }
738
739 if self.auto_remove {
741 cmd = cmd.remove();
742 }
743
744 cmd = cmd.env("RITRUSTEDORIGINS", "http://localhost");
746
747 let output = cmd.execute().await?;
748 Ok(output.0)
749 }
750
751 fn node_image(&self) -> String {
757 if let Some(ref custom_image) = self.redis_image {
758 if let Some(ref tag) = self.redis_tag {
759 format!("{}:{}", custom_image, tag)
760 } else {
761 custom_image.clone()
762 }
763 } else if self.use_redis_stack {
764 self.stack_image()
765 } else {
766 "redis:7-alpine".to_string()
767 }
768 }
769
770 fn stack_image(&self) -> String {
772 format!("{}:{}", REDIS_STACK_SERVER_IMAGE, self.stack_tag)
773 }
774
775 fn insight_image(&self) -> String {
777 format!("{}:{}", REDIS_INSIGHT_CLUSTER_IMAGE, self.redis_insight_tag)
778 }
779
780 fn build_ping_args(&self, node_index: usize) -> Vec<String> {
787 let mut args = vec!["redis-cli".to_string()];
788
789 if self.uses_host_network() {
790 args.push("-p".to_string());
791 args.push(self.node_internal_port(node_index).to_string());
792 }
793
794 self.push_cli_tls_args(&mut args);
795
796 if let Some(ref password) = self.password {
797 args.push("-a".to_string());
798 args.push(password.clone());
799 }
800
801 args.push("ping".to_string());
802 args
803 }
804
805 async fn wait_for_nodes_ready(
811 &self,
812 timeout: std::time::Duration,
813 ) -> Result<(), TemplateError> {
814 let check_interval = std::time::Duration::from_millis(500);
815 let start = std::time::Instant::now();
816
817 let mut pending: Vec<usize> = (0..self.total_nodes()).collect();
818
819 loop {
820 let mut still_pending = Vec::new();
821 for &i in &pending {
822 let node_name = self.node_name(i);
823 let ping_args = self.build_ping_args(i);
824 let ready = ExecCommand::new(&node_name, ping_args)
825 .execute()
826 .await
827 .is_ok_and(|output| output.stdout.trim() == "PONG");
828
829 if !ready {
830 still_pending.push(i);
831 }
832 }
833
834 if still_pending.is_empty() {
835 return Ok(());
836 }
837 pending = still_pending;
838
839 if start.elapsed() >= timeout {
840 let names: Vec<String> = pending.iter().map(|&i| self.node_name(i)).collect();
841 return Err(TemplateError::Timeout(format!(
842 "Cluster '{}' nodes [{}] did not respond to PING within {:?}",
843 self.name,
844 names.join(", "),
845 timeout
846 )));
847 }
848
849 tokio::time::sleep(check_interval).await;
850 }
851 }
852
853 async fn initialize_cluster(&self, container_ids: &[String]) -> Result<(), TemplateError> {
855 if container_ids.is_empty() {
856 return Err(TemplateError::InvalidConfig(
857 "No containers to initialize cluster".to_string(),
858 ));
859 }
860
861 self.wait_for_nodes_ready(std::time::Duration::from_secs(60))
863 .await?;
864
865 let mut create_args = vec![
867 "redis-cli".to_string(),
868 "--cluster".to_string(),
869 "create".to_string(),
870 ];
871
872 for i in 0..self.total_nodes() {
877 create_args.push(self.node_cluster_address(i));
878 }
879
880 if self.num_replicas > 0 {
882 create_args.push("--cluster-replicas".to_string());
883 create_args.push(self.num_replicas.to_string());
884 }
885
886 self.push_cli_tls_args(&mut create_args);
888
889 if let Some(ref password) = self.password {
891 create_args.push("-a".to_string());
892 create_args.push(password.clone());
893 }
894
895 create_args.push("--cluster-yes".to_string());
897
898 let first_node_name = self.node_name(0);
900
901 ExecCommand::new(&first_node_name, create_args)
902 .execute()
903 .await?;
904
905 Ok(())
906 }
907
908 pub async fn cluster_info(&self) -> Result<ClusterInfo, TemplateError> {
910 let node_name = self.node_name(0);
911
912 let mut info_args = vec![
913 "redis-cli".to_string(),
914 "--cluster".to_string(),
915 "info".to_string(),
916 self.node_cluster_address(0),
917 ];
918
919 self.push_cli_tls_args(&mut info_args);
920
921 if let Some(ref password) = self.password {
922 info_args.push("-a".to_string());
923 info_args.push(password.clone());
924 }
925
926 let output = ExecCommand::new(&node_name, info_args).execute().await?;
927
928 ClusterInfo::from_output(&output.stdout)
930 }
931
932 pub async fn is_ready(&self) -> bool {
952 self.cluster_info()
953 .await
954 .is_ok_and(|info| info.cluster_state == "ok")
955 }
956
957 pub async fn wait_until_ready(
982 &self,
983 timeout: std::time::Duration,
984 ) -> Result<(), TemplateError> {
985 let start = std::time::Instant::now();
986
987 while start.elapsed() < timeout {
988 if self.is_ready().await {
989 return Ok(());
990 }
991 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
992 }
993
994 Err(TemplateError::Timeout(format!(
995 "Cluster '{}' did not become ready within {:?}",
996 self.name, timeout
997 )))
998 }
999
1000 pub async fn detect_existing(&self) -> Option<RedisClusterConnection> {
1024 let host = self.announce_ip.as_deref().unwrap_or("localhost");
1025
1026 let first_port = self.port_base;
1028 let addr = format!("{}:{}", host, first_port);
1029
1030 let connect_result = tokio::time::timeout(
1032 std::time::Duration::from_secs(2),
1033 tokio::net::TcpStream::connect(&addr),
1034 )
1035 .await;
1036
1037 match connect_result {
1038 Ok(Ok(_stream)) => {
1039 Some(RedisClusterConnection::from_template(self))
1042 }
1043 _ => None,
1044 }
1045 }
1046
1047 pub async fn start_or_detect(
1069 &self,
1070 timeout: std::time::Duration,
1071 ) -> Result<RedisClusterConnection, TemplateError> {
1072 if let Some(conn) = self.detect_existing().await {
1074 return Ok(conn);
1075 }
1076
1077 self.start().await?;
1079 self.wait_until_ready(timeout).await?;
1080
1081 Ok(RedisClusterConnection::from_template(self))
1082 }
1083}
1084
1085#[async_trait]
1086impl Template for RedisClusterTemplate {
1087 fn name(&self) -> &str {
1088 &self.name
1089 }
1090
1091 fn config(&self) -> &TemplateConfig {
1092 unimplemented!("RedisClusterTemplate manages multiple containers")
1094 }
1095
1096 fn config_mut(&mut self) -> &mut TemplateConfig {
1097 unimplemented!("RedisClusterTemplate manages multiple containers")
1098 }
1099
1100 async fn start(&self) -> Result<String, TemplateError> {
1101 if !self.uses_host_network() {
1104 let _network_id = self.create_network().await?;
1105 }
1106
1107 let mut container_ids = Vec::new();
1109 for i in 0..self.total_nodes() {
1110 let id = self.start_node(i).await?;
1111 container_ids.push(id);
1112 }
1113
1114 self.initialize_cluster(&container_ids).await?;
1116
1117 let insight_info = if self.with_redis_insight {
1119 let _insight_id = self.start_redis_insight().await?;
1120 format!(
1121 ", RedisInsight UI at http://localhost:{}",
1122 self.redis_insight_port
1123 )
1124 } else {
1125 String::new()
1126 };
1127
1128 Ok(format!(
1130 "Redis Cluster '{}' started with {} nodes ({} masters, {} replicas){}",
1131 self.name,
1132 self.total_nodes(),
1133 self.num_masters,
1134 self.num_masters * self.num_replicas,
1135 insight_info
1136 ))
1137 }
1138
1139 async fn stop(&self) -> Result<(), TemplateError> {
1140 use crate::StopCommand;
1141
1142 for i in 0..self.total_nodes() {
1144 let node_name = self.node_name(i);
1145 let _ = StopCommand::new(&node_name).execute().await;
1146 }
1147
1148 if self.with_redis_insight {
1150 let insight_name = format!("{}-insight", self.name);
1151 let _ = StopCommand::new(&insight_name).execute().await;
1152 }
1153
1154 Ok(())
1155 }
1156
1157 async fn remove(&self) -> Result<(), TemplateError> {
1158 use crate::{NetworkRmCommand, RmCommand};
1159
1160 for i in 0..self.total_nodes() {
1162 let node_name = self.node_name(i);
1163 let _ = RmCommand::new(&node_name).force().volumes().execute().await;
1164 }
1165
1166 if self.with_redis_insight {
1168 let insight_name = format!("{}-insight", self.name);
1169 let _ = RmCommand::new(&insight_name)
1170 .force()
1171 .volumes()
1172 .execute()
1173 .await;
1174 }
1175
1176 if !self.uses_host_network() {
1178 let _ = NetworkRmCommand::new(&self.network_name).execute().await;
1179 }
1180
1181 Ok(())
1182 }
1183}
1184
1185#[derive(Debug, Clone)]
1187pub struct ClusterInfo {
1188 pub cluster_state: String,
1190 pub total_slots: u16,
1192 pub nodes: Vec<NodeInfo>,
1194}
1195
1196impl ClusterInfo {
1197 #[allow(clippy::unnecessary_wraps)]
1198 fn from_output(_output: &str) -> Result<Self, TemplateError> {
1199 Ok(ClusterInfo {
1201 cluster_state: "ok".to_string(),
1202 total_slots: 16384,
1203 nodes: Vec::new(),
1204 })
1205 }
1206}
1207
1208#[derive(Debug, Clone, PartialEq, Eq)]
1217pub struct ClusterNode {
1218 pub index: usize,
1220 pub container_name: String,
1222 pub host_port: u16,
1224 pub role: NodeRole,
1231}
1232
1233#[derive(Debug, Clone)]
1235pub struct NodeInfo {
1236 pub id: String,
1238 pub host: String,
1240 pub port: u16,
1242 pub role: NodeRole,
1244 pub slots: Vec<(u16, u16)>,
1246}
1247
1248#[derive(Debug, Clone, PartialEq, Eq)]
1250pub enum NodeRole {
1251 Master,
1253 Replica,
1255}
1256
1257#[derive(Debug, Clone)]
1259pub struct RedisClusterConnection {
1260 nodes: Vec<String>,
1261 password: Option<String>,
1262}
1263
1264impl RedisClusterConnection {
1265 pub fn new(nodes: Vec<String>) -> Self {
1282 Self {
1283 nodes,
1284 password: None,
1285 }
1286 }
1287
1288 pub fn with_password(nodes: Vec<String>, password: impl Into<String>) -> Self {
1301 Self {
1302 nodes,
1303 password: Some(password.into()),
1304 }
1305 }
1306
1307 pub fn from_template(template: &RedisClusterTemplate) -> Self {
1309 let host = template.announce_ip.as_deref().unwrap_or("localhost");
1310 let mut nodes = Vec::new();
1311
1312 for i in 0..template.total_nodes() {
1313 let port = template.port_base + i as u16;
1314 nodes.push(format!("{}:{}", host, port));
1315 }
1316
1317 Self {
1318 nodes,
1319 password: template.password.clone(),
1320 }
1321 }
1322
1323 pub fn nodes(&self) -> &[String] {
1325 &self.nodes
1326 }
1327
1328 pub fn nodes_string(&self) -> String {
1330 self.nodes.join(",")
1331 }
1332
1333 pub fn cluster_url(&self) -> String {
1335 let auth = self
1336 .password
1337 .as_ref()
1338 .map(|p| format!(":{}@", p))
1339 .unwrap_or_default();
1340
1341 format!("redis-cluster://{}{}", auth, self.nodes.join(","))
1342 }
1343}
1344
1345#[cfg(test)]
1346mod tests {
1347 use super::*;
1348 use serial_test::serial;
1349
1350 #[test]
1351 fn test_redis_cluster_template_basic() {
1352 let template = RedisClusterTemplate::new("test-cluster");
1353 assert_eq!(template.name, "test-cluster");
1354 assert_eq!(template.num_masters, 3);
1355 assert_eq!(template.num_replicas, 0);
1356 assert_eq!(template.port_base, 7000);
1357 }
1358
1359 #[test]
1360 fn test_redis_cluster_template_with_replicas() {
1361 let template = RedisClusterTemplate::new("test-cluster")
1362 .num_masters(3)
1363 .num_replicas(1);
1364
1365 assert_eq!(template.total_nodes(), 6);
1366 }
1367
1368 #[test]
1369 fn test_redis_cluster_template_minimum_masters() {
1370 let template = RedisClusterTemplate::new("test-cluster").num_masters(2); assert_eq!(template.num_masters, 3);
1373 }
1374
1375 #[test]
1376 fn test_redis_cluster_connection() {
1377 let template = RedisClusterTemplate::new("test-cluster")
1378 .num_masters(3)
1379 .port_base(7000)
1380 .password("secret");
1381
1382 let conn = RedisClusterConnection::from_template(&template);
1383 assert_eq!(conn.nodes.len(), 3);
1384 assert_eq!(conn.nodes[0], "localhost:7000");
1385 assert_eq!(
1386 conn.cluster_url(),
1387 "redis-cluster://:secret@localhost:7000,localhost:7001,localhost:7002"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_redis_cluster_with_stack_and_insight() {
1393 let template = RedisClusterTemplate::new("test-cluster")
1394 .num_masters(3)
1395 .with_redis_stack()
1396 .with_redis_insight()
1397 .redis_insight_port(8080);
1398
1399 assert!(template.use_redis_stack);
1400 assert!(template.with_redis_insight);
1401 assert_eq!(template.redis_insight_port, 8080);
1402 }
1403
1404 #[test]
1405 fn test_redis_cluster_stack_image_default_pinned() {
1406 let template = RedisClusterTemplate::new("test-cluster").with_redis_stack();
1408
1409 assert_eq!(template.stack_image(), "redis/redis-stack-server:7.4.0-v3");
1410 assert_eq!(template.node_image(), "redis/redis-stack-server:7.4.0-v3");
1411 assert_ne!(template.stack_image(), "redis/redis-stack-server:latest");
1412 }
1413
1414 #[test]
1415 fn test_redis_cluster_stack_version_override() {
1416 let template = RedisClusterTemplate::new("test-cluster")
1417 .with_redis_stack()
1418 .stack_version("7.2.0-v9");
1419
1420 assert_eq!(template.stack_image(), "redis/redis-stack-server:7.2.0-v9");
1421 assert_eq!(template.node_image(), "redis/redis-stack-server:7.2.0-v9");
1422 }
1423
1424 #[test]
1425 fn test_redis_cluster_node_image_default_and_custom() {
1426 let default_template = RedisClusterTemplate::new("test-cluster");
1428 assert_eq!(default_template.node_image(), "redis:7-alpine");
1429
1430 let custom_template = RedisClusterTemplate::new("test-cluster")
1432 .with_redis_stack()
1433 .custom_redis_image("myrepo/redis", "1.2.3");
1434 assert_eq!(custom_template.node_image(), "myrepo/redis:1.2.3");
1435 }
1436
1437 #[test]
1438 fn test_redis_cluster_insight_image_default_pinned() {
1439 let template = RedisClusterTemplate::new("test-cluster").with_redis_insight();
1441
1442 assert_eq!(template.insight_image(), "redislabs/redisinsight:2.60");
1443 assert_ne!(template.insight_image(), "redislabs/redisinsight:latest");
1444 }
1445
1446 #[test]
1447 fn test_redis_cluster_insight_version_override() {
1448 let template = RedisClusterTemplate::new("test-cluster")
1449 .with_redis_insight()
1450 .redis_insight_version("2.58");
1451
1452 assert_eq!(template.insight_image(), "redislabs/redisinsight:2.58");
1453 }
1454
1455 #[test]
1456 fn test_redis_cluster_connection_new() {
1457 let nodes = vec![
1458 "localhost:7000".to_string(),
1459 "localhost:7001".to_string(),
1460 "localhost:7002".to_string(),
1461 ];
1462 let conn = RedisClusterConnection::new(nodes.clone());
1463
1464 assert_eq!(conn.nodes(), &nodes);
1465 assert_eq!(
1466 conn.nodes_string(),
1467 "localhost:7000,localhost:7001,localhost:7002"
1468 );
1469 assert_eq!(
1470 conn.cluster_url(),
1471 "redis-cluster://localhost:7000,localhost:7001,localhost:7002"
1472 );
1473 }
1474
1475 #[test]
1476 fn test_redis_cluster_connection_with_password() {
1477 let nodes = vec!["localhost:7000".to_string()];
1478 let conn = RedisClusterConnection::with_password(nodes, "secret123");
1479
1480 assert_eq!(
1481 conn.cluster_url(),
1482 "redis-cluster://:secret123@localhost:7000"
1483 );
1484 }
1485
1486 #[test]
1487 #[serial]
1488 fn test_redis_cluster_from_env_defaults() {
1489 std::env::remove_var("REDIS_CLUSTER_PORT_BASE");
1491 std::env::remove_var("REDIS_CLUSTER_NUM_MASTERS");
1492 std::env::remove_var("REDIS_CLUSTER_NUM_REPLICAS");
1493 std::env::remove_var("REDIS_CLUSTER_PASSWORD");
1494
1495 let template = RedisClusterTemplate::from_env("test-cluster");
1496
1497 assert_eq!(template.get_port_base(), 7000);
1498 assert_eq!(template.get_num_masters(), 3);
1499 assert_eq!(template.get_num_replicas(), 0);
1500 }
1501
1502 #[test]
1503 #[serial]
1504 fn test_redis_cluster_from_env_with_vars() {
1505 std::env::set_var("REDIS_CLUSTER_PORT_BASE", "8000");
1506 std::env::set_var("REDIS_CLUSTER_NUM_MASTERS", "6");
1507 std::env::set_var("REDIS_CLUSTER_NUM_REPLICAS", "1");
1508 std::env::set_var("REDIS_CLUSTER_PASSWORD", "testpass");
1509
1510 let template = RedisClusterTemplate::from_env("test-cluster");
1511
1512 assert_eq!(template.get_port_base(), 8000);
1513 assert_eq!(template.get_num_masters(), 6);
1514 assert_eq!(template.get_num_replicas(), 1);
1515
1516 std::env::remove_var("REDIS_CLUSTER_PORT_BASE");
1518 std::env::remove_var("REDIS_CLUSTER_NUM_MASTERS");
1519 std::env::remove_var("REDIS_CLUSTER_NUM_REPLICAS");
1520 std::env::remove_var("REDIS_CLUSTER_PASSWORD");
1521 }
1522
1523 #[test]
1524 fn test_build_ping_args_without_password() {
1525 let template = RedisClusterTemplate::new("test-cluster");
1526
1527 assert_eq!(template.build_ping_args(0), vec!["redis-cli", "ping"]);
1529 }
1530
1531 #[test]
1532 fn test_build_ping_args_with_password() {
1533 let template = RedisClusterTemplate::new("test-cluster").password("secret");
1534
1535 assert_eq!(
1536 template.build_ping_args(0),
1537 vec!["redis-cli", "-a", "secret", "ping"]
1538 );
1539 }
1540
1541 #[test]
1542 fn test_redis_cluster_getters() {
1543 let template = RedisClusterTemplate::new("test-cluster")
1544 .port_base(9000)
1545 .num_masters(5)
1546 .num_replicas(2);
1547
1548 assert_eq!(template.get_port_base(), 9000);
1549 assert_eq!(template.get_num_masters(), 5);
1550 assert_eq!(template.get_num_replicas(), 2);
1551 }
1552
1553 #[test]
1554 fn test_node_name_construction() {
1555 let template = RedisClusterTemplate::new("test-cluster");
1556
1557 assert_eq!(template.node_name(0), "test-cluster-node-0");
1558 assert_eq!(template.node_name(2), "test-cluster-node-2");
1559 assert_eq!(template.node_name(11), "test-cluster-node-11");
1560 }
1561
1562 #[test]
1563 fn test_node_names_masters_only() {
1564 let template = RedisClusterTemplate::new("test-cluster").num_masters(3);
1565
1566 assert_eq!(
1567 template.node_names(),
1568 vec![
1569 "test-cluster-node-0",
1570 "test-cluster-node-1",
1571 "test-cluster-node-2",
1572 ]
1573 );
1574 }
1575
1576 #[test]
1577 fn test_node_names_with_replicas() {
1578 let template = RedisClusterTemplate::new("test-cluster")
1579 .num_masters(3)
1580 .num_replicas(1);
1581
1582 let names = template.node_names();
1584 assert_eq!(names.len(), 6);
1585 assert_eq!(names[0], "test-cluster-node-0");
1586 assert_eq!(names[5], "test-cluster-node-5");
1587 }
1588
1589 #[test]
1590 fn test_node_accessor_roles_and_ports() {
1591 let template = RedisClusterTemplate::new("test-cluster")
1592 .num_masters(3)
1593 .num_replicas(1)
1594 .port_base(7000);
1595
1596 for i in 0..3 {
1598 let node = template.node(i).expect("master node exists");
1599 assert_eq!(node.index, i);
1600 assert_eq!(node.container_name, format!("test-cluster-node-{}", i));
1601 assert_eq!(node.host_port, 7000 + i as u16);
1602 assert_eq!(node.role, NodeRole::Master);
1603 }
1604
1605 for i in 3..6 {
1607 let node = template.node(i).expect("replica node exists");
1608 assert_eq!(node.host_port, 7000 + i as u16);
1609 assert_eq!(node.role, NodeRole::Replica);
1610 }
1611 }
1612
1613 #[test]
1614 fn test_node_accessor_out_of_range() {
1615 let template = RedisClusterTemplate::new("test-cluster").num_masters(3);
1616
1617 assert!(template.node(2).is_some());
1618 assert!(template.node(3).is_none());
1619 assert!(template.node(100).is_none());
1620 }
1621
1622 #[test]
1623 fn test_node_accessor_respects_custom_port_base() {
1624 let template = RedisClusterTemplate::new("test-cluster")
1625 .num_masters(3)
1626 .port_base(9100);
1627
1628 assert_eq!(template.node(0).unwrap().host_port, 9100);
1629 assert_eq!(template.node(2).unwrap().host_port, 9102);
1630 }
1631
1632 #[test]
1633 fn test_node_names_match_node_accessor() {
1634 let template = RedisClusterTemplate::new("test-cluster")
1636 .num_masters(3)
1637 .num_replicas(2);
1638
1639 for (i, name) in template.node_names().iter().enumerate() {
1640 assert_eq!(&template.node(i).unwrap().container_name, name);
1641 }
1642 }
1643
1644 #[test]
1645 fn test_host_network_defaults_off() {
1646 let template = RedisClusterTemplate::new("test-cluster");
1647 assert!(!template.uses_host_network());
1648 }
1649
1650 #[test]
1651 fn test_host_network_enables_flag() {
1652 let template = RedisClusterTemplate::new("test-cluster").host_network();
1653 assert!(template.uses_host_network());
1654 }
1655
1656 #[test]
1657 fn test_network_mode_host_enables_host_network() {
1658 let template = RedisClusterTemplate::new("test-cluster").network_mode("host");
1659 assert!(template.uses_host_network());
1660 }
1661
1662 #[test]
1663 fn test_network_mode_non_host_stays_bridge() {
1664 let template = RedisClusterTemplate::new("test-cluster").network_mode("bridge");
1665 assert!(!template.uses_host_network());
1666 }
1667
1668 #[test]
1669 fn test_node_internal_port_bridge_vs_host() {
1670 let bridge = RedisClusterTemplate::new("test-cluster")
1671 .num_masters(3)
1672 .port_base(7000);
1673 assert_eq!(bridge.node_internal_port(0), 6379);
1675 assert_eq!(bridge.node_internal_port(2), 6379);
1676
1677 let host = RedisClusterTemplate::new("test-cluster")
1678 .num_masters(3)
1679 .port_base(7000)
1680 .host_network();
1681 assert_eq!(host.node_internal_port(0), 7000);
1684 assert_eq!(host.node_internal_port(2), 7002);
1685 }
1686
1687 #[test]
1688 fn test_node_cluster_address_bridge_vs_host() {
1689 let bridge = RedisClusterTemplate::new("test-cluster")
1690 .num_masters(3)
1691 .port_base(7000);
1692 assert_eq!(bridge.node_cluster_address(0), "test-cluster-node-0:6379");
1693 assert_eq!(bridge.node_cluster_address(2), "test-cluster-node-2:6379");
1694
1695 let host = RedisClusterTemplate::new("test-cluster")
1696 .num_masters(3)
1697 .port_base(7000)
1698 .host_network();
1699 assert_eq!(host.node_cluster_address(0), "127.0.0.1:7000");
1701 assert_eq!(host.node_cluster_address(2), "127.0.0.1:7002");
1702 }
1703
1704 #[test]
1705 fn test_build_ping_args_host_targets_node_port() {
1706 let host = RedisClusterTemplate::new("test-cluster")
1707 .num_masters(3)
1708 .port_base(7000)
1709 .host_network();
1710 assert_eq!(
1712 host.build_ping_args(1),
1713 vec!["redis-cli", "-p", "7001", "ping"]
1714 );
1715
1716 let bridge = RedisClusterTemplate::new("test-cluster").num_masters(3);
1717 assert_eq!(bridge.build_ping_args(1), vec!["redis-cli", "ping"]);
1719 }
1720
1721 #[test]
1722 fn test_build_ping_args_host_with_password() {
1723 let host = RedisClusterTemplate::new("test-cluster")
1724 .port_base(7000)
1725 .password("secret")
1726 .host_network();
1727 assert_eq!(
1728 host.build_ping_args(0),
1729 vec!["redis-cli", "-p", "7000", "-a", "secret", "ping"]
1730 );
1731 }
1732
1733 #[test]
1734 fn test_host_network_connection_uses_host_ports() {
1735 let template = RedisClusterTemplate::new("test-cluster")
1738 .num_masters(3)
1739 .port_base(7000)
1740 .host_network();
1741
1742 let conn = RedisClusterConnection::from_template(&template);
1743 assert_eq!(
1744 conn.nodes(),
1745 &["localhost:7000", "localhost:7001", "localhost:7002"]
1746 );
1747 assert_eq!(template.node(1).unwrap().host_port, 7001);
1748 }
1749
1750 #[test]
1751 fn test_tls_disabled_by_default() {
1752 let template = RedisClusterTemplate::new("test-cluster");
1753 assert!(!template.tls_enabled());
1754
1755 let mut args = Vec::new();
1757 template.push_cli_tls_args(&mut args);
1758 assert!(args.is_empty());
1759 }
1760
1761 #[test]
1762 fn test_tls_enables_flag() {
1763 let template = RedisClusterTemplate::new("test-cluster").tls("/tmp/certs");
1764 assert!(template.tls_enabled());
1765 }
1766
1767 #[test]
1768 fn test_push_cli_tls_args_when_enabled() {
1769 let template = RedisClusterTemplate::new("test-cluster").tls("/tmp/certs");
1770
1771 let mut args = vec!["redis-cli".to_string()];
1772 template.push_cli_tls_args(&mut args);
1773
1774 assert_eq!(
1775 args,
1776 vec![
1777 "redis-cli",
1778 "--tls",
1779 "--cacert",
1780 "/tls/ca.crt",
1781 "--cert",
1782 "/tls/redis.crt",
1783 "--key",
1784 "/tls/redis.key",
1785 ]
1786 );
1787 }
1788
1789 #[test]
1790 fn test_build_ping_args_with_tls() {
1791 let template = RedisClusterTemplate::new("test-cluster").tls("/tmp/certs");
1792
1793 assert_eq!(
1795 template.build_ping_args(0),
1796 vec![
1797 "redis-cli",
1798 "--tls",
1799 "--cacert",
1800 "/tls/ca.crt",
1801 "--cert",
1802 "/tls/redis.crt",
1803 "--key",
1804 "/tls/redis.key",
1805 "ping",
1806 ]
1807 );
1808 }
1809
1810 #[test]
1811 fn test_build_ping_args_with_tls_and_password() {
1812 let template = RedisClusterTemplate::new("test-cluster")
1813 .tls("/tmp/certs")
1814 .password("secret");
1815
1816 assert_eq!(
1817 template.build_ping_args(0),
1818 vec![
1819 "redis-cli",
1820 "--tls",
1821 "--cacert",
1822 "/tls/ca.crt",
1823 "--cert",
1824 "/tls/redis.crt",
1825 "--key",
1826 "/tls/redis.key",
1827 "-a",
1828 "secret",
1829 "ping",
1830 ]
1831 );
1832 }
1833}