Skip to main content

docker_wrapper/template/redis/
cluster.rs

1//! Redis Cluster template for multi-node Redis setup with sharding and replication
2
3#![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// Each bool is an independent, orthogonal configuration toggle (auto-remove,
10// Redis Stack, RedisInsight, host networking), not a hidden state machine.
11#![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
22/// Redis Cluster template for automatic multi-node cluster setup
23pub struct RedisClusterTemplate {
24    /// Base name for the cluster
25    name: String,
26    /// Number of master nodes (minimum 3)
27    num_masters: usize,
28    /// Number of replicas per master
29    num_replicas: usize,
30    /// Base port for Redis nodes
31    port_base: u16,
32    /// Network name for cluster communication
33    network_name: String,
34    /// Password for cluster authentication
35    password: Option<String>,
36    /// IP to announce to other nodes
37    announce_ip: Option<String>,
38    /// Volume prefix for persistence
39    volume_prefix: Option<String>,
40    /// Memory limit per node
41    memory_limit: Option<String>,
42    /// Cluster node timeout in milliseconds
43    node_timeout: u32,
44    /// Whether to run nodes with `--network host` instead of a bridge network
45    host_network: bool,
46    /// Whether to remove containers on stop
47    auto_remove: bool,
48    /// Whether to use Redis Stack instead of standard Redis
49    use_redis_stack: bool,
50    /// Image tag used for the Redis Stack server image
51    stack_tag: String,
52    /// Whether to include RedisInsight GUI
53    with_redis_insight: bool,
54    /// Port for RedisInsight UI
55    redis_insight_port: u16,
56    /// Image tag used for the RedisInsight image
57    redis_insight_tag: String,
58    /// Custom Redis image
59    redis_image: Option<String>,
60    /// Custom Redis tag
61    redis_tag: Option<String>,
62    /// Platform for containers
63    platform: Option<String>,
64    /// Host directory containing TLS certificate material, mounted read-only
65    /// into every node when TLS is enabled.
66    tls_certs_dir: Option<String>,
67}
68
69impl RedisClusterTemplate {
70    /// Create a new Redis Cluster template with default settings
71    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    /// Create a new Redis Cluster template with settings from environment variables.
101    ///
102    /// Falls back to defaults if environment variables are not set.
103    ///
104    /// # Environment Variables
105    ///
106    /// - `REDIS_CLUSTER_PORT_BASE`: Base port for Redis nodes (default: 7000)
107    /// - `REDIS_CLUSTER_NUM_MASTERS`: Number of master nodes (default: 3)
108    /// - `REDIS_CLUSTER_NUM_REPLICAS`: Number of replicas per master (default: 0)
109    /// - `REDIS_CLUSTER_PASSWORD`: Password for cluster authentication (optional)
110    ///
111    /// # Examples
112    ///
113    /// ```
114    /// use docker_wrapper::RedisClusterTemplate;
115    ///
116    /// // Uses environment variables if set, otherwise uses defaults
117    /// let template = RedisClusterTemplate::from_env("my-cluster");
118    /// ```
119    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    /// Get the configured port base
148    pub fn get_port_base(&self) -> u16 {
149        self.port_base
150    }
151
152    /// Get the configured number of masters
153    pub fn get_num_masters(&self) -> usize {
154        self.num_masters
155    }
156
157    /// Get the configured number of replicas per master
158    pub fn get_num_replicas(&self) -> usize {
159        self.num_replicas
160    }
161
162    /// Set the number of master nodes (minimum 3)
163    pub fn num_masters(mut self, masters: usize) -> Self {
164        self.num_masters = masters.max(3);
165        self
166    }
167
168    /// Set the number of replicas per master
169    pub fn num_replicas(mut self, replicas: usize) -> Self {
170        self.num_replicas = replicas;
171        self
172    }
173
174    /// Set the base port for Redis nodes
175    pub fn port_base(mut self, port: u16) -> Self {
176        self.port_base = port;
177        self
178    }
179
180    /// Set cluster password
181    pub fn password(mut self, password: impl Into<String>) -> Self {
182        self.password = Some(password.into());
183        self
184    }
185
186    /// Set the IP to announce to other cluster nodes
187    pub fn cluster_announce_ip(mut self, ip: impl Into<String>) -> Self {
188        self.announce_ip = Some(ip.into());
189        self
190    }
191
192    /// Enable persistence with volume prefix
193    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    /// Set memory limit per node
199    pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
200        self.memory_limit = Some(limit.into());
201        self
202    }
203
204    /// Set cluster node timeout in milliseconds
205    pub fn cluster_node_timeout(mut self, timeout: u32) -> Self {
206        self.node_timeout = timeout;
207        self
208    }
209
210    /// Select the container network mode for cluster nodes.
211    ///
212    /// Only `"host"` is special-cased: it is equivalent to calling
213    /// [`host_network`](Self::host_network) (see that method for the Linux-only
214    /// caveats). Any other value leaves the cluster on its default,
215    /// automatically managed bridge network, since a multi-node cluster relies
216    /// on a private bridge for inter-node DNS and announce-IP wiring.
217    pub fn network_mode(mut self, mode: impl Into<String>) -> Self {
218        self.host_network = mode.into() == "host";
219        self
220    }
221
222    /// Run every cluster node with `--network host`.
223    ///
224    /// In host networking mode each node shares the host's network namespace,
225    /// so its Redis port is a real host port and no published port mapping or
226    /// `--cluster-announce-ip` ceremony is needed. To keep the nodes from
227    /// colliding on a single shared namespace, each node listens on a distinct
228    /// port derived from the port base (`port_base + index`), which is exactly
229    /// the host port reported by [`node`](Self::node) and
230    /// [`RedisClusterConnection::from_template`]. The private bridge network is
231    /// not created in this mode.
232    ///
233    /// # Platform support
234    ///
235    /// Host networking is a **Linux-only** Docker feature. On Docker Desktop
236    /// for macOS and Windows the daemon runs inside a Linux VM, so
237    /// `--network host` binds ports inside that VM rather than on your machine:
238    /// the option is effectively a **no-op** there and the cluster will not be
239    /// reachable from the host. This method does not return an error on
240    /// non-Linux hosts (the Docker CLI accepts the flag regardless of backend);
241    /// only use host mode against a native Linux daemon, such as a Linux CI
242    /// runner.
243    ///
244    /// # Example
245    ///
246    /// ```rust
247    /// use docker_wrapper::RedisClusterTemplate;
248    ///
249    /// // Linux only: nodes are reachable on localhost:7000, 7001, 7002 with no
250    /// // bridge network and no announce-ip wiring.
251    /// let cluster = RedisClusterTemplate::new("host-cluster")
252    ///     .num_masters(3)
253    ///     .port_base(7000)
254    ///     .host_network();
255    /// ```
256    pub fn host_network(mut self) -> Self {
257        self.host_network = true;
258        self
259    }
260
261    /// Returns true when the cluster is configured for host networking.
262    fn uses_host_network(&self) -> bool {
263        self.host_network
264    }
265
266    /// The port a node listens on *inside* its container.
267    ///
268    /// In the default bridge mode every node listens on 6379 and is told apart
269    /// by container name. In host mode all nodes share the host namespace, so
270    /// each one listens on a distinct `port_base + index` port instead.
271    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    /// The address used to reach a node from inside a sibling container during
280    /// cluster setup and inspection.
281    ///
282    /// Bridge mode uses the container name on the fixed internal port (6379).
283    /// Host mode uses the loopback address on the node's distinct host port,
284    /// since all containers share the host network namespace.
285    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    /// Enable auto-remove when stopped
294    pub fn auto_remove(mut self) -> Self {
295        self.auto_remove = true;
296        self
297    }
298
299    /// Use Redis Stack instead of standard Redis (includes modules like JSON, Search, Graph, TimeSeries, Bloom).
300    ///
301    /// Uses the `redis/redis-stack-server` image pinned to a known-good default
302    /// tag (`7.4.0-v3`) rather than `latest`, so that runs are reproducible.
303    /// Call [`Self::stack_version`] to pin a different tag, or
304    /// [`Self::custom_redis_image`] for full control.
305    pub fn with_redis_stack(mut self) -> Self {
306        self.use_redis_stack = true;
307        self
308    }
309
310    /// Pin the Redis Stack server image tag (e.g. `"7.4.0-v3"`).
311    ///
312    /// Only affects the image used when [`Self::with_redis_stack`] is enabled.
313    /// The default is a known-good pinned tag rather than `latest`, so that runs
314    /// are reproducible. A [`Self::custom_redis_image`] takes precedence over
315    /// this setting.
316    ///
317    /// # Example
318    ///
319    /// ```rust
320    /// use docker_wrapper::RedisClusterTemplate;
321    ///
322    /// let template = RedisClusterTemplate::new("my-cluster")
323    ///     .with_redis_stack()
324    ///     .stack_version("7.4.0-v3");
325    /// ```
326    pub fn stack_version(mut self, tag: impl Into<String>) -> Self {
327        self.stack_tag = tag.into();
328        self
329    }
330
331    /// Enable RedisInsight GUI for cluster visualization and management.
332    ///
333    /// Uses the `redislabs/redisinsight` image pinned to a known-good default
334    /// tag (`2.60`) rather than `latest`, so that runs are reproducible. Call
335    /// [`Self::redis_insight_version`] to pin a different tag.
336    pub fn with_redis_insight(mut self) -> Self {
337        self.with_redis_insight = true;
338        self
339    }
340
341    /// Set the port for RedisInsight UI (default: 8001)
342    pub fn redis_insight_port(mut self, port: u16) -> Self {
343        self.redis_insight_port = port;
344        self
345    }
346
347    /// Pin the RedisInsight image tag (e.g. `"2.60"`).
348    ///
349    /// Only affects the image used when [`Self::with_redis_insight`] is enabled.
350    /// The default is a known-good pinned tag rather than `latest`, so that runs
351    /// are reproducible.
352    ///
353    /// # Example
354    ///
355    /// ```rust
356    /// use docker_wrapper::RedisClusterTemplate;
357    ///
358    /// let template = RedisClusterTemplate::new("my-cluster")
359    ///     .with_redis_insight()
360    ///     .redis_insight_version("2.60");
361    /// ```
362    pub fn redis_insight_version(mut self, tag: impl Into<String>) -> Self {
363        self.redis_insight_tag = tag.into();
364        self
365    }
366
367    /// Use a custom Redis image and tag
368    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    /// Set the platform for the containers (e.g., "linux/arm64", "linux/amd64")
375    pub fn platform(mut self, platform: impl Into<String>) -> Self {
376        self.platform = Some(platform.into());
377        self
378    }
379
380    /// Enable TLS for every cluster node, bind-mounting the given host
381    /// certificate directory read-only into each container.
382    ///
383    /// The directory **must** contain these files (the same layout used by the
384    /// single-node [`RedisTemplate`](super::RedisTemplate)):
385    ///
386    /// - `redis.crt` -- the server certificate
387    /// - `redis.key` -- the server private key
388    /// - `ca.crt` -- the CA certificate used to verify peers
389    ///
390    /// Each node is started with `--tls-port` set to its data port, `--port 0`
391    /// (plaintext disabled), and `--tls-cluster yes`/`--tls-replication yes` so
392    /// that the cluster bus and replication links are encrypted too. This mirrors
393    /// Redis's documented cluster-over-TLS layout: unlike the single-node
394    /// template, a TLS cluster is **always TLS-only** on the data port -- the
395    /// gossip protocol cannot mix plaintext and TLS nodes. The
396    /// `redis-cli --cluster create`, readiness, and inspection calls all connect
397    /// with `--tls` and the mounted certificates.
398    ///
399    /// See [`RedisTemplate::tls`](super::RedisTemplate::tls) for an `openssl`
400    /// recipe to generate throwaway certificates for local testing.
401    ///
402    /// # Example
403    ///
404    /// ```rust
405    /// use docker_wrapper::RedisClusterTemplate;
406    ///
407    /// let cluster = RedisClusterTemplate::new("tls-cluster").tls("/path/to/certs");
408    /// ```
409    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    /// Returns true when TLS has been enabled on this cluster.
415    fn tls_enabled(&self) -> bool {
416        self.tls_certs_dir.is_some()
417    }
418
419    /// Append the `redis-cli` TLS flags (`--tls --cacert --cert --key`) to a
420    /// command vector when TLS is enabled, so management commands can reach the
421    /// TLS-only nodes.
422    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    /// Get the total number of nodes
435    fn total_nodes(&self) -> usize {
436        self.num_masters + (self.num_masters * self.num_replicas)
437    }
438
439    /// Container name for the node at `index`.
440    ///
441    /// Nodes are named deterministically as `{name}-node-{index}`, where
442    /// `index` runs from `0` to `total_nodes() - 1`. This naming contract is
443    /// stable and is relied upon by readiness polling and the per-node
444    /// accessors ([`node_names`](Self::node_names) and [`node`](Self::node)).
445    fn node_name(&self, index: usize) -> String {
446        format!("{}-node-{}", self.name, index)
447    }
448
449    /// List the container names for every node in the cluster.
450    ///
451    /// Names follow the deterministic `{name}-node-{i}` contract, ordered by
452    /// node index from `0` to `total_nodes() - 1`. The first
453    /// [`get_num_masters`](Self::get_num_masters) entries are masters and the
454    /// remainder are replicas, mirroring how `redis-cli --cluster create`
455    /// assigns roles.
456    ///
457    /// This is the building block for targeted per-node fault injection: pick a
458    /// name and pause, partition, or kill exactly that container.
459    ///
460    /// # Examples
461    ///
462    /// ```
463    /// use docker_wrapper::RedisClusterTemplate;
464    ///
465    /// let cluster = RedisClusterTemplate::new("chaos").num_masters(3);
466    /// assert_eq!(
467    ///     cluster.node_names(),
468    ///     vec!["chaos-node-0", "chaos-node-1", "chaos-node-2"],
469    /// );
470    /// ```
471    pub fn node_names(&self) -> Vec<String> {
472        (0..self.total_nodes()).map(|i| self.node_name(i)).collect()
473    }
474
475    /// Get a handle to a single node by index.
476    ///
477    /// Returns a [`ClusterNode`] describing the node's container name, the host
478    /// port mapped to its Redis port, and its expected role, or `None` if
479    /// `index` is out of range (`index >= total_nodes()`).
480    ///
481    /// The returned values are derived from configuration only -- this is a
482    /// plain accessor that performs no Docker calls. The role is the role
483    /// `redis-cli --cluster create` assigns: indices `0..num_masters` are
484    /// masters and the rest are replicas. To read the live role from a running
485    /// node instead (for example after a failover), use
486    /// [`node_role`](Self::node_role).
487    ///
488    /// # Examples
489    ///
490    /// ```
491    /// use docker_wrapper::{NodeRole, RedisClusterTemplate};
492    ///
493    /// let cluster = RedisClusterTemplate::new("chaos")
494    ///     .num_masters(3)
495    ///     .num_replicas(1)
496    ///     .port_base(7000);
497    ///
498    /// let master = cluster.node(0).expect("node 0 exists");
499    /// assert_eq!(master.container_name, "chaos-node-0");
500    /// assert_eq!(master.host_port, 7000);
501    /// assert_eq!(master.role, NodeRole::Master);
502    ///
503    /// // The first three nodes are masters, the last three are replicas.
504    /// let replica = cluster.node(3).expect("node 3 exists");
505    /// assert_eq!(replica.host_port, 7003);
506    /// assert_eq!(replica.role, NodeRole::Replica);
507    ///
508    /// assert!(cluster.node(6).is_none());
509    /// ```
510    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    /// Query the live role of a single node from the running container.
530    ///
531    /// Runs `redis-cli role` inside the node's container and reports whether the
532    /// node currently identifies as a master or a replica. Unlike
533    /// [`node`](Self::node), which returns the role assigned at creation time,
534    /// this reflects the cluster's current state (for example after a failover).
535    /// This performs a Docker `exec` and is therefore not free; the cluster must
536    /// be running.
537    ///
538    /// # Errors
539    ///
540    /// Returns an error if `index` is out of range, if the `docker exec` call
541    /// fails (for example the container is not running), or if the role output
542    /// cannot be parsed.
543    ///
544    /// # Examples
545    ///
546    /// ```no_run
547    /// # use docker_wrapper::{RedisClusterTemplate, Template};
548    /// # #[tokio::main]
549    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
550    /// let cluster = RedisClusterTemplate::new("my-cluster");
551    /// cluster.start().await?;
552    ///
553    /// let role = cluster.node_role(0).await?;
554    /// println!("node 0 is currently a {:?}", role);
555    /// # Ok(())
556    /// # }
557    /// ```
558    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        // `redis-cli role` prints the role keyword ("master" or "slave") on the
585        // first line of its reply.
586        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    /// Create the cluster network
597    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        // Network ID is in stdout
604        Ok(output.stdout.trim().to_string())
605    }
606
607    /// Start a single Redis node
608    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        // In bridge mode the node listens on 6379 and is reached by container
613        // name; in host mode it must listen on its distinct host port.
614        let internal_port = self.node_internal_port(node_index);
615        let cluster_port = port + 10000;
616
617        // Choose image based on custom image or Redis Stack preference
618        let image = self.node_image();
619
620        let mut cmd = RunCommand::new(image).name(&node_name).detach();
621
622        if host_mode {
623            // Host networking: share the host namespace, no published ports.
624            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        // Add memory limit if specified
633        if let Some(ref limit) = self.memory_limit {
634            cmd = cmd.memory(limit);
635        }
636
637        // Add volume for persistence
638        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        // Mount the TLS certificate directory read-only when TLS is enabled.
644        if let Some(ref certs_dir) = self.tls_certs_dir {
645            cmd = cmd.volume_ro(certs_dir, REDIS_TLS_DIR);
646        }
647
648        // Add platform if specified
649        if let Some(ref platform) = self.platform {
650            cmd = cmd.platform(platform);
651        }
652
653        // Auto-remove
654        if self.auto_remove {
655            cmd = cmd.remove();
656        }
657
658        // Build Redis command with cluster configuration
659        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            // Cluster-over-TLS: serve TLS on the node's data port, disable the
673            // plaintext listener, and encrypt the cluster bus and replication
674            // links. This is the layout Redis documents for TLS clusters.
675            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        // Add password if configured
688        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        // Add announce IP if configured. Host networking makes the container
696        // port a real host port, so the announce-ip ceremony is unnecessary and
697        // is skipped entirely.
698        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    /// Start RedisInsight container
716    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            // No bridge network exists in host mode; the UI is reached on the
725            // host port directly.
726            cmd = cmd.network("host");
727        } else {
728            cmd = cmd
729                .network(&self.network_name)
730                .port(self.redis_insight_port, 8001);
731        }
732
733        // Add volume for RedisInsight data persistence
734        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        // Auto-remove
740        if self.auto_remove {
741            cmd = cmd.remove();
742        }
743
744        // Environment variables for RedisInsight
745        cmd = cmd.env("RITRUSTEDORIGINS", "http://localhost");
746
747        let output = cmd.execute().await?;
748        Ok(output.0)
749    }
750
751    /// Resolve the image reference used for each Redis node.
752    ///
753    /// A custom image (via [`custom_redis_image`](Self::custom_redis_image))
754    /// takes precedence; otherwise Redis Stack uses the pinned
755    /// `redis/redis-stack-server` tag and the default is `redis:7-alpine`.
756    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    /// Build the Redis Stack server image reference (pinned tag by default).
771    fn stack_image(&self) -> String {
772        format!("{}:{}", REDIS_STACK_SERVER_IMAGE, self.stack_tag)
773    }
774
775    /// Build the RedisInsight image reference (pinned tag by default).
776    fn insight_image(&self) -> String {
777        format!("{}:{}", REDIS_INSIGHT_CLUSTER_IMAGE, self.redis_insight_tag)
778    }
779
780    /// Build the redis-cli ping arguments used for node readiness checks.
781    ///
782    /// In host networking mode each node listens on its own `port_base + index`
783    /// port rather than the default 6379, so an explicit `-p` is added to target
784    /// the right node inside the shared host namespace. When TLS is enabled the
785    /// `--tls` flags are appended so the check can reach the TLS-only node.
786    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    /// Wait for all cluster nodes to respond to PING.
806    ///
807    /// Polls each node with `redis-cli ping` (the same readiness check used
808    /// by `wait_for_ready()` on single-node templates) every 500ms until all
809    /// nodes reply with PONG or the timeout is exceeded.
810    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    /// Initialize the cluster after all nodes are started
854    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        // Wait until every node accepts connections before running cluster create
862        self.wait_for_nodes_ready(std::time::Duration::from_secs(60))
863            .await?;
864
865        // Build the cluster create command
866        let mut create_args = vec![
867            "redis-cli".to_string(),
868            "--cluster".to_string(),
869            "create".to_string(),
870        ];
871
872        // Add all node addresses. In bridge mode nodes are reached by container
873        // name on the fixed internal port 6379. In host mode every node shares
874        // the host namespace, so they are reached on 127.0.0.1 at their distinct
875        // host ports (port_base + index).
876        for i in 0..self.total_nodes() {
877            create_args.push(self.node_cluster_address(i));
878        }
879
880        // Add replicas configuration
881        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        // Connect over TLS when enabled (nodes are TLS-only).
887        self.push_cli_tls_args(&mut create_args);
888
889        // Add password if configured
890        if let Some(ref password) = self.password {
891            create_args.push("-a".to_string());
892            create_args.push(password.clone());
893        }
894
895        // Auto-accept the configuration
896        create_args.push("--cluster-yes".to_string());
897
898        // Execute cluster create in the first container
899        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    /// Check cluster status
909    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        // Parse the cluster info output
929        ClusterInfo::from_output(&output.stdout)
930    }
931
932    /// Check if the cluster is ready (all nodes up, slots assigned).
933    ///
934    /// Returns `true` if the cluster state is "ok", `false` otherwise.
935    ///
936    /// # Examples
937    ///
938    /// ```no_run
939    /// # use docker_wrapper::{RedisClusterTemplate, Template};
940    /// # #[tokio::main]
941    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
942    /// let template = RedisClusterTemplate::new("my-cluster");
943    /// template.start().await?;
944    ///
945    /// if template.is_ready().await {
946    ///     println!("Cluster is ready!");
947    /// }
948    /// # Ok(())
949    /// # }
950    /// ```
951    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    /// Wait for the cluster to become ready, with a timeout.
958    ///
959    /// Polls the cluster state every 500ms until it reports "ok" or the timeout is exceeded.
960    ///
961    /// # Errors
962    ///
963    /// Returns an error if the timeout is exceeded before the cluster becomes ready.
964    ///
965    /// # Examples
966    ///
967    /// ```no_run
968    /// # use docker_wrapper::{RedisClusterTemplate, Template};
969    /// # use std::time::Duration;
970    /// # #[tokio::main]
971    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
972    /// let template = RedisClusterTemplate::new("my-cluster");
973    /// template.start().await?;
974    ///
975    /// // Wait up to 30 seconds for the cluster to be ready
976    /// template.wait_until_ready(Duration::from_secs(30)).await?;
977    /// println!("Cluster is ready!");
978    /// # Ok(())
979    /// # }
980    /// ```
981    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    /// Check if a Redis cluster is already running at the configured ports.
1001    ///
1002    /// This is useful in CI environments where an external cluster may be
1003    /// provided (e.g., via `grokzen/redis-cluster` Docker image).
1004    ///
1005    /// Returns connection info if a cluster is detected, `None` otherwise.
1006    ///
1007    /// # Examples
1008    ///
1009    /// ```no_run
1010    /// # use docker_wrapper::RedisClusterTemplate;
1011    /// # #[tokio::main]
1012    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1013    /// let template = RedisClusterTemplate::from_env("my-cluster");
1014    ///
1015    /// if let Some(conn) = template.detect_existing().await {
1016    ///     println!("Found existing cluster: {}", conn.nodes_string());
1017    /// } else {
1018    ///     println!("No existing cluster found");
1019    /// }
1020    /// # Ok(())
1021    /// # }
1022    /// ```
1023    pub async fn detect_existing(&self) -> Option<RedisClusterConnection> {
1024        let host = self.announce_ip.as_deref().unwrap_or("localhost");
1025
1026        // Try to connect to the first node
1027        let first_port = self.port_base;
1028        let addr = format!("{}:{}", host, first_port);
1029
1030        // Try TCP connection with a short timeout
1031        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                // Connection succeeded - cluster appears to be running
1040                // Build connection info for all expected nodes
1041                Some(RedisClusterConnection::from_template(self))
1042            }
1043            _ => None,
1044        }
1045    }
1046
1047    /// Start the cluster, or use an existing one if already running.
1048    ///
1049    /// This provides a "best of both worlds" approach for hybrid local/CI setups:
1050    /// - In CI: Uses the externally-provided cluster without starting new containers
1051    /// - Locally: Starts a new cluster via docker-wrapper
1052    ///
1053    /// # Examples
1054    ///
1055    /// ```no_run
1056    /// # use docker_wrapper::RedisClusterTemplate;
1057    /// # use std::time::Duration;
1058    /// # #[tokio::main]
1059    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1060    /// // Works in both CI (uses existing) and local (starts new)
1061    /// let template = RedisClusterTemplate::from_env("test-cluster");
1062    /// let conn = template.start_or_detect(Duration::from_secs(60)).await?;
1063    ///
1064    /// println!("Cluster ready at: {}", conn.nodes_string());
1065    /// # Ok(())
1066    /// # }
1067    /// ```
1068    pub async fn start_or_detect(
1069        &self,
1070        timeout: std::time::Duration,
1071    ) -> Result<RedisClusterConnection, TemplateError> {
1072        // First, check if a cluster already exists
1073        if let Some(conn) = self.detect_existing().await {
1074            return Ok(conn);
1075        }
1076
1077        // No existing cluster found - start a new one
1078        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        // Return a dummy config as cluster doesn't map to single container
1093        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        // Create the private bridge network first. Host networking shares the
1102        // host namespace, so no bridge network is created in that mode.
1103        if !self.uses_host_network() {
1104            let _network_id = self.create_network().await?;
1105        }
1106
1107        // Start all nodes
1108        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        // Initialize the cluster
1115        self.initialize_cluster(&container_ids).await?;
1116
1117        // Start RedisInsight if enabled
1118        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        // Return a summary
1129        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        // Stop all nodes
1143        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        // Stop RedisInsight if it was started
1149        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        // Remove all containers
1161        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        // Remove RedisInsight if it was started
1167        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        // Remove the network. None is created in host networking mode.
1177        if !self.uses_host_network() {
1178            let _ = NetworkRmCommand::new(&self.network_name).execute().await;
1179        }
1180
1181        Ok(())
1182    }
1183}
1184
1185/// Cluster information
1186#[derive(Debug, Clone)]
1187pub struct ClusterInfo {
1188    /// Current state of the cluster (ok/fail)
1189    pub cluster_state: String,
1190    /// Total number of hash slots (always 16384 for Redis)
1191    pub total_slots: u16,
1192    /// List of nodes in the cluster
1193    pub nodes: Vec<NodeInfo>,
1194}
1195
1196impl ClusterInfo {
1197    #[allow(clippy::unnecessary_wraps)]
1198    fn from_output(_output: &str) -> Result<Self, TemplateError> {
1199        // Basic parsing - would need more sophisticated parsing in production
1200        Ok(ClusterInfo {
1201            cluster_state: "ok".to_string(),
1202            total_slots: 16384,
1203            nodes: Vec::new(),
1204        })
1205    }
1206}
1207
1208/// A deterministic handle to a single node in a [`RedisClusterTemplate`].
1209///
1210/// Returned by [`RedisClusterTemplate::node`]. The fields are derived purely
1211/// from the template configuration (the `{name}-node-{index}` naming contract,
1212/// the port base, and the master/replica split applied by
1213/// `redis-cli --cluster create`), so constructing a handle is free and does not
1214/// require the cluster to be running. This makes it suitable for targeted fault
1215/// injection: pause, partition, or kill exactly the container you name.
1216#[derive(Debug, Clone, PartialEq, Eq)]
1217pub struct ClusterNode {
1218    /// Zero-based index of the node within the cluster.
1219    pub index: usize,
1220    /// Container name, following the `{name}-node-{index}` contract.
1221    pub container_name: String,
1222    /// Host port mapped to this node's Redis port (`port_base + index`).
1223    pub host_port: u16,
1224    /// Role assigned to the node at cluster-create time.
1225    ///
1226    /// This is the static assignment (`0..num_masters` are masters, the rest are
1227    /// replicas), not necessarily the live role after a failover. Use
1228    /// [`RedisClusterTemplate::node_role`] to read the current role from a
1229    /// running node.
1230    pub role: NodeRole,
1231}
1232
1233/// Information about a cluster node
1234#[derive(Debug, Clone)]
1235pub struct NodeInfo {
1236    /// Node ID in the cluster
1237    pub id: String,
1238    /// Hostname or IP address
1239    pub host: String,
1240    /// Port number
1241    pub port: u16,
1242    /// Role of the node (Master/Replica)
1243    pub role: NodeRole,
1244    /// Slot ranges assigned to this node (start, end)
1245    pub slots: Vec<(u16, u16)>,
1246}
1247
1248/// Node role in the cluster
1249#[derive(Debug, Clone, PartialEq, Eq)]
1250pub enum NodeRole {
1251    /// Master node that owns hash slots
1252    Master,
1253    /// Replica node that replicates a master
1254    Replica,
1255}
1256
1257/// Connection helper for Redis Cluster
1258#[derive(Debug, Clone)]
1259pub struct RedisClusterConnection {
1260    nodes: Vec<String>,
1261    password: Option<String>,
1262}
1263
1264impl RedisClusterConnection {
1265    /// Create a new cluster connection with the given node addresses.
1266    ///
1267    /// This is useful for connecting to external/pre-existing clusters
1268    /// (e.g., in CI environments) without going through a template.
1269    ///
1270    /// # Examples
1271    ///
1272    /// ```
1273    /// use docker_wrapper::RedisClusterConnection;
1274    ///
1275    /// let conn = RedisClusterConnection::new(vec![
1276    ///     "localhost:7000".to_string(),
1277    ///     "localhost:7001".to_string(),
1278    ///     "localhost:7002".to_string(),
1279    /// ]);
1280    /// ```
1281    pub fn new(nodes: Vec<String>) -> Self {
1282        Self {
1283            nodes,
1284            password: None,
1285        }
1286    }
1287
1288    /// Create a new cluster connection with password authentication.
1289    ///
1290    /// # Examples
1291    ///
1292    /// ```
1293    /// use docker_wrapper::RedisClusterConnection;
1294    ///
1295    /// let conn = RedisClusterConnection::with_password(
1296    ///     vec!["localhost:7000".to_string()],
1297    ///     "secret",
1298    /// );
1299    /// ```
1300    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    /// Create from a RedisClusterTemplate
1308    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    /// Get the list of cluster nodes
1324    pub fn nodes(&self) -> &[String] {
1325        &self.nodes
1326    }
1327
1328    /// Get cluster nodes as comma-separated string
1329    pub fn nodes_string(&self) -> String {
1330        self.nodes.join(",")
1331    }
1332
1333    /// Get connection URL for cluster-aware clients
1334    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); // Should be forced to 3
1371
1372        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        // Redis Stack defaults to a pinned, known-good tag (not latest).
1407        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        // Default (no stack, no custom) is the pinned alpine image.
1427        let default_template = RedisClusterTemplate::new("test-cluster");
1428        assert_eq!(default_template.node_image(), "redis:7-alpine");
1429
1430        // A custom image takes precedence over the stack preference.
1431        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        // RedisInsight defaults to a pinned, known-good tag (not latest).
1440        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        // Clear any existing env vars to ensure defaults are used
1490        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        // Clean up
1517        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        // Bridge mode: node listens on the default port, so no -p is emitted.
1528        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        // 3 masters + 3 replicas = 6 nodes, indices 0..6
1583        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        // First num_masters nodes are masters.
1597        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        // Remaining nodes are replicas.
1606        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        // node_names() and node().container_name must agree on every index.
1635        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        // Bridge mode: every node listens on the fixed internal port.
1674        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        // Host mode: distinct port per node so they do not collide in the
1682        // shared host namespace.
1683        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        // Host mode reaches siblings over loopback on their distinct ports.
1700        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        // Host mode pings the node's distinct port explicitly.
1711        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        // Bridge mode pings the default port, so no -p is needed.
1718        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        // External clients connect to the distinct host ports, which match the
1736        // per-node host_port accessor.
1737        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        // No TLS flags leak into the management commands.
1756        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        // Bridge mode + TLS: default port, but TLS flags are appended.
1794        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}