@@ -76,6 +76,68 @@ impl RedisClusterTemplate {
7676 }
7777 }
7878
79+ /// Create a new Redis Cluster template with settings from environment variables.
80+ ///
81+ /// Falls back to defaults if environment variables are not set.
82+ ///
83+ /// # Environment Variables
84+ ///
85+ /// - `REDIS_CLUSTER_PORT_BASE`: Base port for Redis nodes (default: 7000)
86+ /// - `REDIS_CLUSTER_NUM_MASTERS`: Number of master nodes (default: 3)
87+ /// - `REDIS_CLUSTER_NUM_REPLICAS`: Number of replicas per master (default: 0)
88+ /// - `REDIS_CLUSTER_PASSWORD`: Password for cluster authentication (optional)
89+ ///
90+ /// # Examples
91+ ///
92+ /// ```
93+ /// use docker_wrapper::RedisClusterTemplate;
94+ ///
95+ /// // Uses environment variables if set, otherwise uses defaults
96+ /// let template = RedisClusterTemplate::from_env("my-cluster");
97+ /// ```
98+ pub fn from_env ( name : impl Into < String > ) -> Self {
99+ let mut template = Self :: new ( name) ;
100+
101+ if let Ok ( port_base) = std:: env:: var ( "REDIS_CLUSTER_PORT_BASE" ) {
102+ if let Ok ( port) = port_base. parse :: < u16 > ( ) {
103+ template. port_base = port;
104+ }
105+ }
106+
107+ if let Ok ( num_masters) = std:: env:: var ( "REDIS_CLUSTER_NUM_MASTERS" ) {
108+ if let Ok ( masters) = num_masters. parse :: < usize > ( ) {
109+ template. num_masters = masters. max ( 3 ) ;
110+ }
111+ }
112+
113+ if let Ok ( num_replicas) = std:: env:: var ( "REDIS_CLUSTER_NUM_REPLICAS" ) {
114+ if let Ok ( replicas) = num_replicas. parse :: < usize > ( ) {
115+ template. num_replicas = replicas;
116+ }
117+ }
118+
119+ if let Ok ( password) = std:: env:: var ( "REDIS_CLUSTER_PASSWORD" ) {
120+ template. password = Some ( password) ;
121+ }
122+
123+ template
124+ }
125+
126+ /// Get the configured port base
127+ pub fn get_port_base ( & self ) -> u16 {
128+ self . port_base
129+ }
130+
131+ /// Get the configured number of masters
132+ pub fn get_num_masters ( & self ) -> usize {
133+ self . num_masters
134+ }
135+
136+ /// Get the configured number of replicas per master
137+ pub fn get_num_replicas ( & self ) -> usize {
138+ self . num_replicas
139+ }
140+
79141 /// Set the number of master nodes (minimum 3)
80142 pub fn num_masters ( mut self , masters : usize ) -> Self {
81143 self . num_masters = masters. max ( 3 ) ;
@@ -362,6 +424,159 @@ impl RedisClusterTemplate {
362424 // Parse the cluster info output
363425 ClusterInfo :: from_output ( & output. stdout )
364426 }
427+
428+ /// Check if the cluster is ready (all nodes up, slots assigned).
429+ ///
430+ /// Returns `true` if the cluster state is "ok", `false` otherwise.
431+ ///
432+ /// # Examples
433+ ///
434+ /// ```no_run
435+ /// # use docker_wrapper::{RedisClusterTemplate, Template};
436+ /// # #[tokio::main]
437+ /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
438+ /// let template = RedisClusterTemplate::new("my-cluster");
439+ /// template.start().await?;
440+ ///
441+ /// if template.is_ready().await {
442+ /// println!("Cluster is ready!");
443+ /// }
444+ /// # Ok(())
445+ /// # }
446+ /// ```
447+ pub async fn is_ready ( & self ) -> bool {
448+ self . cluster_info ( )
449+ . await
450+ . map ( |info| info. cluster_state == "ok" )
451+ . unwrap_or ( false )
452+ }
453+
454+ /// Wait for the cluster to become ready, with a timeout.
455+ ///
456+ /// Polls the cluster state every 500ms until it reports "ok" or the timeout is exceeded.
457+ ///
458+ /// # Errors
459+ ///
460+ /// Returns an error if the timeout is exceeded before the cluster becomes ready.
461+ ///
462+ /// # Examples
463+ ///
464+ /// ```no_run
465+ /// # use docker_wrapper::{RedisClusterTemplate, Template};
466+ /// # use std::time::Duration;
467+ /// # #[tokio::main]
468+ /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
469+ /// let template = RedisClusterTemplate::new("my-cluster");
470+ /// template.start().await?;
471+ ///
472+ /// // Wait up to 30 seconds for the cluster to be ready
473+ /// template.wait_until_ready(Duration::from_secs(30)).await?;
474+ /// println!("Cluster is ready!");
475+ /// # Ok(())
476+ /// # }
477+ /// ```
478+ pub async fn wait_until_ready (
479+ & self ,
480+ timeout : std:: time:: Duration ,
481+ ) -> Result < ( ) , TemplateError > {
482+ let start = std:: time:: Instant :: now ( ) ;
483+
484+ while start. elapsed ( ) < timeout {
485+ if self . is_ready ( ) . await {
486+ return Ok ( ( ) ) ;
487+ }
488+ tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 500 ) ) . await ;
489+ }
490+
491+ Err ( TemplateError :: Timeout ( format ! (
492+ "Cluster '{}' did not become ready within {:?}" ,
493+ self . name, timeout
494+ ) ) )
495+ }
496+
497+ /// Check if a Redis cluster is already running at the configured ports.
498+ ///
499+ /// This is useful in CI environments where an external cluster may be
500+ /// provided (e.g., via `grokzen/redis-cluster` Docker image).
501+ ///
502+ /// Returns connection info if a cluster is detected, `None` otherwise.
503+ ///
504+ /// # Examples
505+ ///
506+ /// ```no_run
507+ /// # use docker_wrapper::RedisClusterTemplate;
508+ /// # #[tokio::main]
509+ /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
510+ /// let template = RedisClusterTemplate::from_env("my-cluster");
511+ ///
512+ /// if let Some(conn) = template.detect_existing().await {
513+ /// println!("Found existing cluster: {}", conn.nodes_string());
514+ /// } else {
515+ /// println!("No existing cluster found");
516+ /// }
517+ /// # Ok(())
518+ /// # }
519+ /// ```
520+ pub async fn detect_existing ( & self ) -> Option < RedisClusterConnection > {
521+ let host = self . announce_ip . as_deref ( ) . unwrap_or ( "localhost" ) ;
522+
523+ // Try to connect to the first node
524+ let first_port = self . port_base ;
525+ let addr = format ! ( "{}:{}" , host, first_port) ;
526+
527+ // Try TCP connection with a short timeout
528+ let connect_result = tokio:: time:: timeout (
529+ std:: time:: Duration :: from_secs ( 2 ) ,
530+ tokio:: net:: TcpStream :: connect ( & addr) ,
531+ )
532+ . await ;
533+
534+ match connect_result {
535+ Ok ( Ok ( _stream) ) => {
536+ // Connection succeeded - cluster appears to be running
537+ // Build connection info for all expected nodes
538+ Some ( RedisClusterConnection :: from_template ( self ) )
539+ }
540+ _ => None ,
541+ }
542+ }
543+
544+ /// Start the cluster, or use an existing one if already running.
545+ ///
546+ /// This provides a "best of both worlds" approach for hybrid local/CI setups:
547+ /// - In CI: Uses the externally-provided cluster without starting new containers
548+ /// - Locally: Starts a new cluster via docker-wrapper
549+ ///
550+ /// # Examples
551+ ///
552+ /// ```no_run
553+ /// # use docker_wrapper::RedisClusterTemplate;
554+ /// # use std::time::Duration;
555+ /// # #[tokio::main]
556+ /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
557+ /// // Works in both CI (uses existing) and local (starts new)
558+ /// let template = RedisClusterTemplate::from_env("test-cluster");
559+ /// let conn = template.start_or_detect(Duration::from_secs(60)).await?;
560+ ///
561+ /// println!("Cluster ready at: {}", conn.nodes_string());
562+ /// # Ok(())
563+ /// # }
564+ /// ```
565+ pub async fn start_or_detect (
566+ & self ,
567+ timeout : std:: time:: Duration ,
568+ ) -> Result < RedisClusterConnection , TemplateError > {
569+ // First, check if a cluster already exists
570+ if let Some ( conn) = self . detect_existing ( ) . await {
571+ return Ok ( conn) ;
572+ }
573+
574+ // No existing cluster found - start a new one
575+ self . start ( ) . await ?;
576+ self . wait_until_ready ( timeout) . await ?;
577+
578+ Ok ( RedisClusterConnection :: from_template ( self ) )
579+ }
365580}
366581
367582#[ async_trait]
@@ -507,12 +722,55 @@ pub enum NodeRole {
507722}
508723
509724/// Connection helper for Redis Cluster
725+ #[ derive( Debug , Clone ) ]
510726pub struct RedisClusterConnection {
511727 nodes : Vec < String > ,
512728 password : Option < String > ,
513729}
514730
515731impl RedisClusterConnection {
732+ /// Create a new cluster connection with the given node addresses.
733+ ///
734+ /// This is useful for connecting to external/pre-existing clusters
735+ /// (e.g., in CI environments) without going through a template.
736+ ///
737+ /// # Examples
738+ ///
739+ /// ```
740+ /// use docker_wrapper::RedisClusterConnection;
741+ ///
742+ /// let conn = RedisClusterConnection::new(vec![
743+ /// "localhost:7000".to_string(),
744+ /// "localhost:7001".to_string(),
745+ /// "localhost:7002".to_string(),
746+ /// ]);
747+ /// ```
748+ pub fn new ( nodes : Vec < String > ) -> Self {
749+ Self {
750+ nodes,
751+ password : None ,
752+ }
753+ }
754+
755+ /// Create a new cluster connection with password authentication.
756+ ///
757+ /// # Examples
758+ ///
759+ /// ```
760+ /// use docker_wrapper::RedisClusterConnection;
761+ ///
762+ /// let conn = RedisClusterConnection::with_password(
763+ /// vec!["localhost:7000".to_string()],
764+ /// "secret",
765+ /// );
766+ /// ```
767+ pub fn with_password ( nodes : Vec < String > , password : impl Into < String > ) -> Self {
768+ Self {
769+ nodes,
770+ password : Some ( password. into ( ) ) ,
771+ }
772+ }
773+
516774 /// Create from a RedisClusterTemplate
517775 pub fn from_template ( template : & RedisClusterTemplate ) -> Self {
518776 let host = template. announce_ip . as_deref ( ) . unwrap_or ( "localhost" ) ;
@@ -529,6 +787,11 @@ impl RedisClusterConnection {
529787 }
530788 }
531789
790+ /// Get the list of cluster nodes
791+ pub fn nodes ( & self ) -> & [ String ] {
792+ & self . nodes
793+ }
794+
532795 /// Get cluster nodes as comma-separated string
533796 pub fn nodes_string ( & self ) -> String {
534797 self . nodes . join ( "," )
@@ -603,4 +866,82 @@ mod tests {
603866 assert ! ( template. with_redis_insight) ;
604867 assert_eq ! ( template. redis_insight_port, 8080 ) ;
605868 }
869+
870+ #[ test]
871+ fn test_redis_cluster_connection_new ( ) {
872+ let nodes = vec ! [
873+ "localhost:7000" . to_string( ) ,
874+ "localhost:7001" . to_string( ) ,
875+ "localhost:7002" . to_string( ) ,
876+ ] ;
877+ let conn = RedisClusterConnection :: new ( nodes. clone ( ) ) ;
878+
879+ assert_eq ! ( conn. nodes( ) , & nodes) ;
880+ assert_eq ! (
881+ conn. nodes_string( ) ,
882+ "localhost:7000,localhost:7001,localhost:7002"
883+ ) ;
884+ assert_eq ! (
885+ conn. cluster_url( ) ,
886+ "redis-cluster://localhost:7000,localhost:7001,localhost:7002"
887+ ) ;
888+ }
889+
890+ #[ test]
891+ fn test_redis_cluster_connection_with_password ( ) {
892+ let nodes = vec ! [ "localhost:7000" . to_string( ) ] ;
893+ let conn = RedisClusterConnection :: with_password ( nodes, "secret123" ) ;
894+
895+ assert_eq ! (
896+ conn. cluster_url( ) ,
897+ "redis-cluster://:secret123@localhost:7000"
898+ ) ;
899+ }
900+
901+ #[ test]
902+ fn test_redis_cluster_from_env_defaults ( ) {
903+ // Clear any existing env vars to ensure defaults are used
904+ std:: env:: remove_var ( "REDIS_CLUSTER_PORT_BASE" ) ;
905+ std:: env:: remove_var ( "REDIS_CLUSTER_NUM_MASTERS" ) ;
906+ std:: env:: remove_var ( "REDIS_CLUSTER_NUM_REPLICAS" ) ;
907+ std:: env:: remove_var ( "REDIS_CLUSTER_PASSWORD" ) ;
908+
909+ let template = RedisClusterTemplate :: from_env ( "test-cluster" ) ;
910+
911+ assert_eq ! ( template. get_port_base( ) , 7000 ) ;
912+ assert_eq ! ( template. get_num_masters( ) , 3 ) ;
913+ assert_eq ! ( template. get_num_replicas( ) , 0 ) ;
914+ }
915+
916+ #[ test]
917+ fn test_redis_cluster_from_env_with_vars ( ) {
918+ std:: env:: set_var ( "REDIS_CLUSTER_PORT_BASE" , "8000" ) ;
919+ std:: env:: set_var ( "REDIS_CLUSTER_NUM_MASTERS" , "6" ) ;
920+ std:: env:: set_var ( "REDIS_CLUSTER_NUM_REPLICAS" , "1" ) ;
921+ std:: env:: set_var ( "REDIS_CLUSTER_PASSWORD" , "testpass" ) ;
922+
923+ let template = RedisClusterTemplate :: from_env ( "test-cluster" ) ;
924+
925+ assert_eq ! ( template. get_port_base( ) , 8000 ) ;
926+ assert_eq ! ( template. get_num_masters( ) , 6 ) ;
927+ assert_eq ! ( template. get_num_replicas( ) , 1 ) ;
928+
929+ // Clean up
930+ std:: env:: remove_var ( "REDIS_CLUSTER_PORT_BASE" ) ;
931+ std:: env:: remove_var ( "REDIS_CLUSTER_NUM_MASTERS" ) ;
932+ std:: env:: remove_var ( "REDIS_CLUSTER_NUM_REPLICAS" ) ;
933+ std:: env:: remove_var ( "REDIS_CLUSTER_PASSWORD" ) ;
934+ }
935+
936+ #[ test]
937+ fn test_redis_cluster_getters ( ) {
938+ let template = RedisClusterTemplate :: new ( "test-cluster" )
939+ . port_base ( 9000 )
940+ . num_masters ( 5 )
941+ . num_replicas ( 2 ) ;
942+
943+ assert_eq ! ( template. get_port_base( ) , 9000 ) ;
944+ assert_eq ! ( template. get_num_masters( ) , 5 ) ;
945+ assert_eq ! ( template. get_num_replicas( ) , 2 ) ;
946+ }
606947}
0 commit comments