Skip to content

Commit 81bb5a7

Browse files
feat: enhance RedisClusterTemplate for CI and hybrid environments (#211)
* feat: enhance RedisClusterTemplate for CI and hybrid environments Add new features to improve usability in CI environments and hybrid local/CI setups: RedisClusterConnection (#207): - Add new() constructor for direct node list creation - Add with_password() for authenticated connections - Add nodes() getter for accessing node list Environment configuration (#208): - Add from_env() to configure templates via environment variables - Supports REDIS_CLUSTER_PORT_BASE, REDIS_CLUSTER_NUM_MASTERS, REDIS_CLUSTER_NUM_REPLICAS, REDIS_CLUSTER_PASSWORD - Add get_port_base(), get_num_masters(), get_num_replicas() getters Health check helpers (#209): - Add is_ready() to check if cluster state is ok - Add wait_until_ready(timeout) for polling until ready - Add TemplateError::Timeout variant for timeout errors Hybrid setup support (#210): - Add detect_existing() to find running clusters at configured ports - Add start_or_detect(timeout) for best-of-both-worlds CI/local support Closes #207, #208, #209, #210 * fix: add missing Template import in doc examples for is_ready and wait_until_ready
1 parent 3eb8c2b commit 81bb5a7

2 files changed

Lines changed: 345 additions & 0 deletions

File tree

src/template.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ pub enum TemplateError {
5656
/// Attempted to operate on a template that is not running
5757
#[error("Template not running: {0}")]
5858
NotRunning(String),
59+
60+
/// Operation timed out waiting for a condition
61+
#[error("Timeout: {0}")]
62+
Timeout(String),
5963
}
6064

6165
/// Configuration for a Docker template

src/template/redis/cluster.rs

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
510726
pub struct RedisClusterConnection {
511727
nodes: Vec<String>,
512728
password: Option<String>,
513729
}
514730

515731
impl 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

Comments
 (0)